Merge branch 'main' into feat/hitl-frontend

This commit is contained in:
twwu 2025-12-26 12:32:29 +08:00
commit 5a1e6269d5
83 changed files with 6077 additions and 714 deletions

View File

@ -0,0 +1,483 @@
---
name: component-refactoring
description: Refactor high-complexity React components in Dify frontend. Use when `pnpm analyze-component --json` shows complexity > 50 or lineCount > 300, when the user asks for code splitting, hook extraction, or complexity reduction, or when `pnpm analyze-component` warns to refactor before testing; avoid for simple/well-structured components, third-party wrappers, or when the user explicitly wants testing without refactoring.
---
# Dify Component Refactoring Skill
Refactor high-complexity React components in the Dify frontend codebase with the patterns and workflow below.
> **Complexity Threshold**: Components with complexity > 50 (measured by `pnpm analyze-component`) should be refactored before testing.
## Quick Reference
### Commands (run from `web/`)
Use paths relative to `web/` (e.g., `app/components/...`).
Use `refactor-component` for refactoring prompts and `analyze-component` for testing prompts and metrics.
```bash
cd web
# Generate refactoring prompt
pnpm refactor-component <path>
# Output refactoring analysis as JSON
pnpm refactor-component <path> --json
# Generate testing prompt (after refactoring)
pnpm analyze-component <path>
# Output testing analysis as JSON
pnpm analyze-component <path> --json
```
### Complexity Analysis
```bash
# Analyze component complexity
pnpm analyze-component <path> --json
# Key metrics to check:
# - complexity: normalized score 0-100 (target < 50)
# - maxComplexity: highest single function complexity
# - lineCount: total lines (target < 300)
```
### Complexity Score Interpretation
| Score | Level | Action |
|-------|-------|--------|
| 0-25 | 🟢 Simple | Ready for testing |
| 26-50 | 🟡 Medium | Consider minor refactoring |
| 51-75 | 🟠 Complex | **Refactor before testing** |
| 76-100 | 🔴 Very Complex | **Must refactor** |
## Core Refactoring Patterns
### Pattern 1: Extract Custom Hooks
**When**: Component has complex state management, multiple `useState`/`useEffect`, or business logic mixed with UI.
**Dify Convention**: Place hooks in a `hooks/` subdirectory or alongside the component as `use-<feature>.ts`.
```typescript
// ❌ Before: Complex state logic in component
const Configuration: FC = () => {
const [modelConfig, setModelConfig] = useState<ModelConfig>(...)
const [datasetConfigs, setDatasetConfigs] = useState<DatasetConfigs>(...)
const [completionParams, setCompletionParams] = useState<FormValue>({})
// 50+ lines of state management logic...
return <div>...</div>
}
// ✅ After: Extract to custom hook
// hooks/use-model-config.ts
export const useModelConfig = (appId: string) => {
const [modelConfig, setModelConfig] = useState<ModelConfig>(...)
const [completionParams, setCompletionParams] = useState<FormValue>({})
// Related state management logic here
return { modelConfig, setModelConfig, completionParams, setCompletionParams }
}
// Component becomes cleaner
const Configuration: FC = () => {
const { modelConfig, setModelConfig } = useModelConfig(appId)
return <div>...</div>
}
```
**Dify Examples**:
- `web/app/components/app/configuration/hooks/use-advanced-prompt-config.ts`
- `web/app/components/app/configuration/debug/hooks.tsx`
- `web/app/components/workflow/hooks/use-workflow.ts`
### Pattern 2: Extract Sub-Components
**When**: Single component has multiple UI sections, conditional rendering blocks, or repeated patterns.
**Dify Convention**: Place sub-components in subdirectories or as separate files in the same directory.
```typescript
// ❌ Before: Monolithic JSX with multiple sections
const AppInfo = () => {
return (
<div>
{/* 100 lines of header UI */}
{/* 100 lines of operations UI */}
{/* 100 lines of modals */}
</div>
)
}
// ✅ After: Split into focused components
// app-info/
// ├── index.tsx (orchestration only)
// ├── app-header.tsx (header UI)
// ├── app-operations.tsx (operations UI)
// └── app-modals.tsx (modal management)
const AppInfo = () => {
const { showModal, setShowModal } = useAppInfoModals()
return (
<div>
<AppHeader appDetail={appDetail} />
<AppOperations onAction={handleAction} />
<AppModals show={showModal} onClose={() => setShowModal(null)} />
</div>
)
}
```
**Dify Examples**:
- `web/app/components/app/configuration/` directory structure
- `web/app/components/workflow/nodes/` per-node organization
### Pattern 3: Simplify Conditional Logic
**When**: Deep nesting (> 3 levels), complex ternaries, or multiple `if/else` chains.
```typescript
// ❌ Before: Deeply nested conditionals
const Template = useMemo(() => {
if (appDetail?.mode === AppModeEnum.CHAT) {
switch (locale) {
case LanguagesSupported[1]:
return <TemplateChatZh />
case LanguagesSupported[7]:
return <TemplateChatJa />
default:
return <TemplateChatEn />
}
}
if (appDetail?.mode === AppModeEnum.ADVANCED_CHAT) {
// Another 15 lines...
}
// More conditions...
}, [appDetail, locale])
// ✅ After: Use lookup tables + early returns
const TEMPLATE_MAP = {
[AppModeEnum.CHAT]: {
[LanguagesSupported[1]]: TemplateChatZh,
[LanguagesSupported[7]]: TemplateChatJa,
default: TemplateChatEn,
},
[AppModeEnum.ADVANCED_CHAT]: {
[LanguagesSupported[1]]: TemplateAdvancedChatZh,
// ...
},
}
const Template = useMemo(() => {
const modeTemplates = TEMPLATE_MAP[appDetail?.mode]
if (!modeTemplates) return null
const TemplateComponent = modeTemplates[locale] || modeTemplates.default
return <TemplateComponent appDetail={appDetail} />
}, [appDetail, locale])
```
### Pattern 4: Extract API/Data Logic
**When**: Component directly handles API calls, data transformation, or complex async operations.
**Dify Convention**: Use `@tanstack/react-query` hooks from `web/service/use-*.ts` or create custom data hooks. Project is migrating from SWR to React Query.
```typescript
// ❌ Before: API logic in component
const MCPServiceCard = () => {
const [basicAppConfig, setBasicAppConfig] = useState({})
useEffect(() => {
if (isBasicApp && appId) {
(async () => {
const res = await fetchAppDetail({ url: '/apps', id: appId })
setBasicAppConfig(res?.model_config || {})
})()
}
}, [appId, isBasicApp])
// More API-related logic...
}
// ✅ After: Extract to data hook using React Query
// use-app-config.ts
import { useQuery } from '@tanstack/react-query'
import { get } from '@/service/base'
const NAME_SPACE = 'appConfig'
export const useAppConfig = (appId: string, isBasicApp: boolean) => {
return useQuery({
enabled: isBasicApp && !!appId,
queryKey: [NAME_SPACE, 'detail', appId],
queryFn: () => get<AppDetailResponse>(`/apps/${appId}`),
select: data => data?.model_config || {},
})
}
// Component becomes cleaner
const MCPServiceCard = () => {
const { data: config, isLoading } = useAppConfig(appId, isBasicApp)
// UI only
}
```
**React Query Best Practices in Dify**:
- Define `NAME_SPACE` for query key organization
- Use `enabled` option for conditional fetching
- Use `select` for data transformation
- Export invalidation hooks: `useInvalidXxx`
**Dify Examples**:
- `web/service/use-workflow.ts`
- `web/service/use-common.ts`
- `web/service/knowledge/use-dataset.ts`
- `web/service/knowledge/use-document.ts`
### Pattern 5: Extract Modal/Dialog Management
**When**: Component manages multiple modals with complex open/close states.
**Dify Convention**: Modals should be extracted with their state management.
```typescript
// ❌ Before: Multiple modal states in component
const AppInfo = () => {
const [showEditModal, setShowEditModal] = useState(false)
const [showDuplicateModal, setShowDuplicateModal] = useState(false)
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
const [showSwitchModal, setShowSwitchModal] = useState(false)
const [showImportDSLModal, setShowImportDSLModal] = useState(false)
// 5+ more modal states...
}
// ✅ After: Extract to modal management hook
type ModalType = 'edit' | 'duplicate' | 'delete' | 'switch' | 'import' | null
const useAppInfoModals = () => {
const [activeModal, setActiveModal] = useState<ModalType>(null)
const openModal = useCallback((type: ModalType) => setActiveModal(type), [])
const closeModal = useCallback(() => setActiveModal(null), [])
return {
activeModal,
openModal,
closeModal,
isOpen: (type: ModalType) => activeModal === type,
}
}
```
### Pattern 6: Extract Form Logic
**When**: Complex form validation, submission handling, or field transformation.
**Dify Convention**: Use `@tanstack/react-form` patterns from `web/app/components/base/form/`.
```typescript
// ✅ Use existing form infrastructure
import { useAppForm } from '@/app/components/base/form'
const ConfigForm = () => {
const form = useAppForm({
defaultValues: { name: '', description: '' },
onSubmit: handleSubmit,
})
return <form.Provider>...</form.Provider>
}
```
## Dify-Specific Refactoring Guidelines
### 1. Context Provider Extraction
**When**: Component provides complex context values with multiple states.
```typescript
// ❌ Before: Large context value object
const value = {
appId, isAPIKeySet, isTrailFinished, mode, modelModeType,
promptMode, isAdvancedMode, isAgent, isOpenAI, isFunctionCall,
// 50+ more properties...
}
return <ConfigContext.Provider value={value}>...</ConfigContext.Provider>
// ✅ After: Split into domain-specific contexts
<ModelConfigProvider value={modelConfigValue}>
<DatasetConfigProvider value={datasetConfigValue}>
<UIConfigProvider value={uiConfigValue}>
{children}
</UIConfigProvider>
</DatasetConfigProvider>
</ModelConfigProvider>
```
**Dify Reference**: `web/context/` directory structure
### 2. Workflow Node Components
**When**: Refactoring workflow node components (`web/app/components/workflow/nodes/`).
**Conventions**:
- Keep node logic in `use-interactions.ts`
- Extract panel UI to separate files
- Use `_base` components for common patterns
```
nodes/<node-type>/
├── index.tsx # Node registration
├── node.tsx # Node visual component
├── panel.tsx # Configuration panel
├── use-interactions.ts # Node-specific hooks
└── types.ts # Type definitions
```
### 3. Configuration Components
**When**: Refactoring app configuration components.
**Conventions**:
- Separate config sections into subdirectories
- Use existing patterns from `web/app/components/app/configuration/`
- Keep feature toggles in dedicated components
### 4. Tool/Plugin Components
**When**: Refactoring tool-related components (`web/app/components/tools/`).
**Conventions**:
- Follow existing modal patterns
- Use service hooks from `web/service/use-tools.ts`
- Keep provider-specific logic isolated
## Refactoring Workflow
### Step 1: Generate Refactoring Prompt
```bash
pnpm refactor-component <path>
```
This command will:
- Analyze component complexity and features
- Identify specific refactoring actions needed
- Generate a prompt for AI assistant (auto-copied to clipboard on macOS)
- Provide detailed requirements based on detected patterns
### Step 2: Analyze Details
```bash
pnpm analyze-component <path> --json
```
Identify:
- Total complexity score
- Max function complexity
- Line count
- Features detected (state, effects, API, etc.)
### Step 3: Plan
Create a refactoring plan based on detected features:
| Detected Feature | Refactoring Action |
|------------------|-------------------|
| `hasState: true` + `hasEffects: true` | Extract custom hook |
| `hasAPI: true` | Extract data/service hook |
| `hasEvents: true` (many) | Extract event handlers |
| `lineCount > 300` | Split into sub-components |
| `maxComplexity > 50` | Simplify conditional logic |
### Step 4: Execute Incrementally
1. **Extract one piece at a time**
2. **Run lint, type-check, and tests after each extraction**
3. **Verify functionality before next step**
```
For each extraction:
┌────────────────────────────────────────┐
│ 1. Extract code │
│ 2. Run: pnpm lint:fix │
│ 3. Run: pnpm type-check:tsgo │
│ 4. Run: pnpm test │
│ 5. Test functionality manually │
│ 6. PASS? → Next extraction │
│ FAIL? → Fix before continuing │
└────────────────────────────────────────┘
```
### Step 5: Verify
After refactoring:
```bash
# Re-run refactor command to verify improvements
pnpm refactor-component <path>
# If complexity < 25 and lines < 200, you'll see:
# ✅ COMPONENT IS WELL-STRUCTURED
# For detailed metrics:
pnpm analyze-component <path> --json
# Target metrics:
# - complexity < 50
# - lineCount < 300
# - maxComplexity < 30
```
## Common Mistakes to Avoid
### ❌ Over-Engineering
```typescript
// ❌ Too many tiny hooks
const useButtonText = () => useState('Click')
const useButtonDisabled = () => useState(false)
const useButtonLoading = () => useState(false)
// ✅ Cohesive hook with related state
const useButtonState = () => {
const [text, setText] = useState('Click')
const [disabled, setDisabled] = useState(false)
const [loading, setLoading] = useState(false)
return { text, setText, disabled, setDisabled, loading, setLoading }
}
```
### ❌ Breaking Existing Patterns
- Follow existing directory structures
- Maintain naming conventions
- Preserve export patterns for compatibility
### ❌ Premature Abstraction
- Only extract when there's clear complexity benefit
- Don't create abstractions for single-use code
- Keep refactored code in the same domain area
## References
### Dify Codebase Examples
- **Hook extraction**: `web/app/components/app/configuration/hooks/`
- **Component splitting**: `web/app/components/app/configuration/`
- **Service hooks**: `web/service/use-*.ts`
- **Workflow patterns**: `web/app/components/workflow/hooks/`
- **Form patterns**: `web/app/components/base/form/`
### Related Skills
- `frontend-testing` - For testing refactored components
- `web/testing/testing.md` - Testing specification

View File

@ -0,0 +1,493 @@
# Complexity Reduction Patterns
This document provides patterns for reducing cognitive complexity in Dify React components.
## Understanding Complexity
### SonarJS Cognitive Complexity
The `pnpm analyze-component` tool uses SonarJS cognitive complexity metrics:
- **Total Complexity**: Sum of all functions' complexity in the file
- **Max Complexity**: Highest single function complexity
### What Increases Complexity
| Pattern | Complexity Impact |
|---------|-------------------|
| `if/else` | +1 per branch |
| Nested conditions | +1 per nesting level |
| `switch/case` | +1 per case |
| `for/while/do` | +1 per loop |
| `&&`/`||` chains | +1 per operator |
| Nested callbacks | +1 per nesting level |
| `try/catch` | +1 per catch |
| Ternary expressions | +1 per nesting |
## Pattern 1: Replace Conditionals with Lookup Tables
**Before** (complexity: ~15):
```typescript
const Template = useMemo(() => {
if (appDetail?.mode === AppModeEnum.CHAT) {
switch (locale) {
case LanguagesSupported[1]:
return <TemplateChatZh appDetail={appDetail} />
case LanguagesSupported[7]:
return <TemplateChatJa appDetail={appDetail} />
default:
return <TemplateChatEn appDetail={appDetail} />
}
}
if (appDetail?.mode === AppModeEnum.ADVANCED_CHAT) {
switch (locale) {
case LanguagesSupported[1]:
return <TemplateAdvancedChatZh appDetail={appDetail} />
case LanguagesSupported[7]:
return <TemplateAdvancedChatJa appDetail={appDetail} />
default:
return <TemplateAdvancedChatEn appDetail={appDetail} />
}
}
if (appDetail?.mode === AppModeEnum.WORKFLOW) {
// Similar pattern...
}
return null
}, [appDetail, locale])
```
**After** (complexity: ~3):
```typescript
// Define lookup table outside component
const TEMPLATE_MAP: Record<AppModeEnum, Record<string, FC<TemplateProps>>> = {
[AppModeEnum.CHAT]: {
[LanguagesSupported[1]]: TemplateChatZh,
[LanguagesSupported[7]]: TemplateChatJa,
default: TemplateChatEn,
},
[AppModeEnum.ADVANCED_CHAT]: {
[LanguagesSupported[1]]: TemplateAdvancedChatZh,
[LanguagesSupported[7]]: TemplateAdvancedChatJa,
default: TemplateAdvancedChatEn,
},
[AppModeEnum.WORKFLOW]: {
[LanguagesSupported[1]]: TemplateWorkflowZh,
[LanguagesSupported[7]]: TemplateWorkflowJa,
default: TemplateWorkflowEn,
},
// ...
}
// Clean component logic
const Template = useMemo(() => {
if (!appDetail?.mode) return null
const templates = TEMPLATE_MAP[appDetail.mode]
if (!templates) return null
const TemplateComponent = templates[locale] ?? templates.default
return <TemplateComponent appDetail={appDetail} />
}, [appDetail, locale])
```
## Pattern 2: Use Early Returns
**Before** (complexity: ~10):
```typescript
const handleSubmit = () => {
if (isValid) {
if (hasChanges) {
if (isConnected) {
submitData()
} else {
showConnectionError()
}
} else {
showNoChangesMessage()
}
} else {
showValidationError()
}
}
```
**After** (complexity: ~4):
```typescript
const handleSubmit = () => {
if (!isValid) {
showValidationError()
return
}
if (!hasChanges) {
showNoChangesMessage()
return
}
if (!isConnected) {
showConnectionError()
return
}
submitData()
}
```
## Pattern 3: Extract Complex Conditions
**Before** (complexity: high):
```typescript
const canPublish = (() => {
if (mode !== AppModeEnum.COMPLETION) {
if (!isAdvancedMode)
return true
if (modelModeType === ModelModeType.completion) {
if (!hasSetBlockStatus.history || !hasSetBlockStatus.query)
return false
return true
}
return true
}
return !promptEmpty
})()
```
**After** (complexity: lower):
```typescript
// Extract to named functions
const canPublishInCompletionMode = () => !promptEmpty
const canPublishInChatMode = () => {
if (!isAdvancedMode) return true
if (modelModeType !== ModelModeType.completion) return true
return hasSetBlockStatus.history && hasSetBlockStatus.query
}
// Clean main logic
const canPublish = mode === AppModeEnum.COMPLETION
? canPublishInCompletionMode()
: canPublishInChatMode()
```
## Pattern 4: Replace Chained Ternaries
**Before** (complexity: ~5):
```typescript
const statusText = serverActivated
? t('status.running')
: serverPublished
? t('status.inactive')
: appUnpublished
? t('status.unpublished')
: t('status.notConfigured')
```
**After** (complexity: ~2):
```typescript
const getStatusText = () => {
if (serverActivated) return t('status.running')
if (serverPublished) return t('status.inactive')
if (appUnpublished) return t('status.unpublished')
return t('status.notConfigured')
}
const statusText = getStatusText()
```
Or use lookup:
```typescript
const STATUS_TEXT_MAP = {
running: 'status.running',
inactive: 'status.inactive',
unpublished: 'status.unpublished',
notConfigured: 'status.notConfigured',
} as const
const getStatusKey = (): keyof typeof STATUS_TEXT_MAP => {
if (serverActivated) return 'running'
if (serverPublished) return 'inactive'
if (appUnpublished) return 'unpublished'
return 'notConfigured'
}
const statusText = t(STATUS_TEXT_MAP[getStatusKey()])
```
## Pattern 5: Flatten Nested Loops
**Before** (complexity: high):
```typescript
const processData = (items: Item[]) => {
const results: ProcessedItem[] = []
for (const item of items) {
if (item.isValid) {
for (const child of item.children) {
if (child.isActive) {
for (const prop of child.properties) {
if (prop.value !== null) {
results.push({
itemId: item.id,
childId: child.id,
propValue: prop.value,
})
}
}
}
}
}
}
return results
}
```
**After** (complexity: lower):
```typescript
// Use functional approach
const processData = (items: Item[]) => {
return items
.filter(item => item.isValid)
.flatMap(item =>
item.children
.filter(child => child.isActive)
.flatMap(child =>
child.properties
.filter(prop => prop.value !== null)
.map(prop => ({
itemId: item.id,
childId: child.id,
propValue: prop.value,
}))
)
)
}
```
## Pattern 6: Extract Event Handler Logic
**Before** (complexity: high in component):
```typescript
const Component = () => {
const handleSelect = (data: DataSet[]) => {
if (isEqual(data.map(item => item.id), dataSets.map(item => item.id))) {
hideSelectDataSet()
return
}
formattingChangedDispatcher()
let newDatasets = data
if (data.find(item => !item.name)) {
const newSelected = produce(data, (draft) => {
data.forEach((item, index) => {
if (!item.name) {
const newItem = dataSets.find(i => i.id === item.id)
if (newItem)
draft[index] = newItem
}
})
})
setDataSets(newSelected)
newDatasets = newSelected
}
else {
setDataSets(data)
}
hideSelectDataSet()
// 40 more lines of logic...
}
return <div>...</div>
}
```
**After** (complexity: lower):
```typescript
// Extract to hook or utility
const useDatasetSelection = (dataSets: DataSet[], setDataSets: SetState<DataSet[]>) => {
const normalizeSelection = (data: DataSet[]) => {
const hasUnloadedItem = data.some(item => !item.name)
if (!hasUnloadedItem) return data
return produce(data, (draft) => {
data.forEach((item, index) => {
if (!item.name) {
const existing = dataSets.find(i => i.id === item.id)
if (existing) draft[index] = existing
}
})
})
}
const hasSelectionChanged = (newData: DataSet[]) => {
return !isEqual(
newData.map(item => item.id),
dataSets.map(item => item.id)
)
}
return { normalizeSelection, hasSelectionChanged }
}
// Component becomes cleaner
const Component = () => {
const { normalizeSelection, hasSelectionChanged } = useDatasetSelection(dataSets, setDataSets)
const handleSelect = (data: DataSet[]) => {
if (!hasSelectionChanged(data)) {
hideSelectDataSet()
return
}
formattingChangedDispatcher()
const normalized = normalizeSelection(data)
setDataSets(normalized)
hideSelectDataSet()
}
return <div>...</div>
}
```
## Pattern 7: Reduce Boolean Logic Complexity
**Before** (complexity: ~8):
```typescript
const toggleDisabled = hasInsufficientPermissions
|| appUnpublished
|| missingStartNode
|| triggerModeDisabled
|| (isAdvancedApp && !currentWorkflow?.graph)
|| (isBasicApp && !basicAppConfig.updated_at)
```
**After** (complexity: ~3):
```typescript
// Extract meaningful boolean functions
const isAppReady = () => {
if (isAdvancedApp) return !!currentWorkflow?.graph
return !!basicAppConfig.updated_at
}
const hasRequiredPermissions = () => {
return isCurrentWorkspaceEditor && !hasInsufficientPermissions
}
const canToggle = () => {
if (!hasRequiredPermissions()) return false
if (!isAppReady()) return false
if (missingStartNode) return false
if (triggerModeDisabled) return false
return true
}
const toggleDisabled = !canToggle()
```
## Pattern 8: Simplify useMemo/useCallback Dependencies
**Before** (complexity: multiple recalculations):
```typescript
const payload = useMemo(() => {
let parameters: Parameter[] = []
let outputParameters: OutputParameter[] = []
if (!published) {
parameters = (inputs || []).map((item) => ({
name: item.variable,
description: '',
form: 'llm',
required: item.required,
type: item.type,
}))
outputParameters = (outputs || []).map((item) => ({
name: item.variable,
description: '',
type: item.value_type,
}))
}
else if (detail && detail.tool) {
parameters = (inputs || []).map((item) => ({
// Complex transformation...
}))
outputParameters = (outputs || []).map((item) => ({
// Complex transformation...
}))
}
return {
icon: detail?.icon || icon,
label: detail?.label || name,
// ...more fields
}
}, [detail, published, workflowAppId, icon, name, description, inputs, outputs])
```
**After** (complexity: separated concerns):
```typescript
// Separate transformations
const useParameterTransform = (inputs: InputVar[], detail?: ToolDetail, published?: boolean) => {
return useMemo(() => {
if (!published) {
return inputs.map(item => ({
name: item.variable,
description: '',
form: 'llm',
required: item.required,
type: item.type,
}))
}
if (!detail?.tool) return []
return inputs.map(item => ({
name: item.variable,
required: item.required,
type: item.type === 'paragraph' ? 'string' : item.type,
description: detail.tool.parameters.find(p => p.name === item.variable)?.llm_description || '',
form: detail.tool.parameters.find(p => p.name === item.variable)?.form || 'llm',
}))
}, [inputs, detail, published])
}
// Component uses hook
const parameters = useParameterTransform(inputs, detail, published)
const outputParameters = useOutputTransform(outputs, detail, published)
const payload = useMemo(() => ({
icon: detail?.icon || icon,
label: detail?.label || name,
parameters,
outputParameters,
// ...
}), [detail, icon, name, parameters, outputParameters])
```
## Target Metrics After Refactoring
| Metric | Target |
|--------|--------|
| Total Complexity | < 50 |
| Max Function Complexity | < 30 |
| Function Length | < 30 lines |
| Nesting Depth | ≤ 3 levels |
| Conditional Chains | ≤ 3 conditions |

View File

@ -0,0 +1,477 @@
# Component Splitting Patterns
This document provides detailed guidance on splitting large components into smaller, focused components in Dify.
## When to Split Components
Split a component when you identify:
1. **Multiple UI sections** - Distinct visual areas with minimal coupling that can be composed independently
1. **Conditional rendering blocks** - Large `{condition && <JSX />}` blocks
1. **Repeated patterns** - Similar UI structures used multiple times
1. **300+ lines** - Component exceeds manageable size
1. **Modal clusters** - Multiple modals rendered in one component
## Splitting Strategies
### Strategy 1: Section-Based Splitting
Identify visual sections and extract each as a component.
```typescript
// ❌ Before: Monolithic component (500+ lines)
const ConfigurationPage = () => {
return (
<div>
{/* Header Section - 50 lines */}
<div className="header">
<h1>{t('configuration.title')}</h1>
<div className="actions">
{isAdvancedMode && <Badge>Advanced</Badge>}
<ModelParameterModal ... />
<AppPublisher ... />
</div>
</div>
{/* Config Section - 200 lines */}
<div className="config">
<Config />
</div>
{/* Debug Section - 150 lines */}
<div className="debug">
<Debug ... />
</div>
{/* Modals Section - 100 lines */}
{showSelectDataSet && <SelectDataSet ... />}
{showHistoryModal && <EditHistoryModal ... />}
{showUseGPT4Confirm && <Confirm ... />}
</div>
)
}
// ✅ After: Split into focused components
// configuration/
// ├── index.tsx (orchestration)
// ├── configuration-header.tsx
// ├── configuration-content.tsx
// ├── configuration-debug.tsx
// └── configuration-modals.tsx
// configuration-header.tsx
interface ConfigurationHeaderProps {
isAdvancedMode: boolean
onPublish: () => void
}
const ConfigurationHeader: FC<ConfigurationHeaderProps> = ({
isAdvancedMode,
onPublish,
}) => {
const { t } = useTranslation()
return (
<div className="header">
<h1>{t('configuration.title')}</h1>
<div className="actions">
{isAdvancedMode && <Badge>Advanced</Badge>}
<ModelParameterModal ... />
<AppPublisher onPublish={onPublish} />
</div>
</div>
)
}
// index.tsx (orchestration only)
const ConfigurationPage = () => {
const { modelConfig, setModelConfig } = useModelConfig()
const { activeModal, openModal, closeModal } = useModalState()
return (
<div>
<ConfigurationHeader
isAdvancedMode={isAdvancedMode}
onPublish={handlePublish}
/>
<ConfigurationContent
modelConfig={modelConfig}
onConfigChange={setModelConfig}
/>
{!isMobile && (
<ConfigurationDebug
inputs={inputs}
onSetting={handleSetting}
/>
)}
<ConfigurationModals
activeModal={activeModal}
onClose={closeModal}
/>
</div>
)
}
```
### Strategy 2: Conditional Block Extraction
Extract large conditional rendering blocks.
```typescript
// ❌ Before: Large conditional blocks
const AppInfo = () => {
return (
<div>
{expand ? (
<div className="expanded">
{/* 100 lines of expanded view */}
</div>
) : (
<div className="collapsed">
{/* 50 lines of collapsed view */}
</div>
)}
</div>
)
}
// ✅ After: Separate view components
const AppInfoExpanded: FC<AppInfoViewProps> = ({ appDetail, onAction }) => {
return (
<div className="expanded">
{/* Clean, focused expanded view */}
</div>
)
}
const AppInfoCollapsed: FC<AppInfoViewProps> = ({ appDetail, onAction }) => {
return (
<div className="collapsed">
{/* Clean, focused collapsed view */}
</div>
)
}
const AppInfo = () => {
return (
<div>
{expand
? <AppInfoExpanded appDetail={appDetail} onAction={handleAction} />
: <AppInfoCollapsed appDetail={appDetail} onAction={handleAction} />
}
</div>
)
}
```
### Strategy 3: Modal Extraction
Extract modals with their trigger logic.
```typescript
// ❌ Before: Multiple modals in one component
const AppInfo = () => {
const [showEdit, setShowEdit] = useState(false)
const [showDuplicate, setShowDuplicate] = useState(false)
const [showDelete, setShowDelete] = useState(false)
const [showSwitch, setShowSwitch] = useState(false)
const onEdit = async (data) => { /* 20 lines */ }
const onDuplicate = async (data) => { /* 20 lines */ }
const onDelete = async () => { /* 15 lines */ }
return (
<div>
{/* Main content */}
{showEdit && <EditModal onConfirm={onEdit} onClose={() => setShowEdit(false)} />}
{showDuplicate && <DuplicateModal onConfirm={onDuplicate} onClose={() => setShowDuplicate(false)} />}
{showDelete && <DeleteConfirm onConfirm={onDelete} onClose={() => setShowDelete(false)} />}
{showSwitch && <SwitchModal ... />}
</div>
)
}
// ✅ After: Modal manager component
// app-info-modals.tsx
type ModalType = 'edit' | 'duplicate' | 'delete' | 'switch' | null
interface AppInfoModalsProps {
appDetail: AppDetail
activeModal: ModalType
onClose: () => void
onSuccess: () => void
}
const AppInfoModals: FC<AppInfoModalsProps> = ({
appDetail,
activeModal,
onClose,
onSuccess,
}) => {
const handleEdit = async (data) => { /* logic */ }
const handleDuplicate = async (data) => { /* logic */ }
const handleDelete = async () => { /* logic */ }
return (
<>
{activeModal === 'edit' && (
<EditModal
appDetail={appDetail}
onConfirm={handleEdit}
onClose={onClose}
/>
)}
{activeModal === 'duplicate' && (
<DuplicateModal
appDetail={appDetail}
onConfirm={handleDuplicate}
onClose={onClose}
/>
)}
{activeModal === 'delete' && (
<DeleteConfirm
onConfirm={handleDelete}
onClose={onClose}
/>
)}
{activeModal === 'switch' && (
<SwitchModal
appDetail={appDetail}
onClose={onClose}
/>
)}
</>
)
}
// Parent component
const AppInfo = () => {
const { activeModal, openModal, closeModal } = useModalState()
return (
<div>
{/* Main content with openModal triggers */}
<Button onClick={() => openModal('edit')}>Edit</Button>
<AppInfoModals
appDetail={appDetail}
activeModal={activeModal}
onClose={closeModal}
onSuccess={handleSuccess}
/>
</div>
)
}
```
### Strategy 4: List Item Extraction
Extract repeated item rendering.
```typescript
// ❌ Before: Inline item rendering
const OperationsList = () => {
return (
<div>
{operations.map(op => (
<div key={op.id} className="operation-item">
<span className="icon">{op.icon}</span>
<span className="title">{op.title}</span>
<span className="description">{op.description}</span>
<button onClick={() => op.onClick()}>
{op.actionLabel}
</button>
{op.badge && <Badge>{op.badge}</Badge>}
{/* More complex rendering... */}
</div>
))}
</div>
)
}
// ✅ After: Extracted item component
interface OperationItemProps {
operation: Operation
onAction: (id: string) => void
}
const OperationItem: FC<OperationItemProps> = ({ operation, onAction }) => {
return (
<div className="operation-item">
<span className="icon">{operation.icon}</span>
<span className="title">{operation.title}</span>
<span className="description">{operation.description}</span>
<button onClick={() => onAction(operation.id)}>
{operation.actionLabel}
</button>
{operation.badge && <Badge>{operation.badge}</Badge>}
</div>
)
}
const OperationsList = () => {
const handleAction = useCallback((id: string) => {
const op = operations.find(o => o.id === id)
op?.onClick()
}, [operations])
return (
<div>
{operations.map(op => (
<OperationItem
key={op.id}
operation={op}
onAction={handleAction}
/>
))}
</div>
)
}
```
## Directory Structure Patterns
### Pattern A: Flat Structure (Simple Components)
For components with 2-3 sub-components:
```
component-name/
├── index.tsx # Main component
├── sub-component-a.tsx
├── sub-component-b.tsx
└── types.ts # Shared types
```
### Pattern B: Nested Structure (Complex Components)
For components with many sub-components:
```
component-name/
├── index.tsx # Main orchestration
├── types.ts # Shared types
├── hooks/
│ ├── use-feature-a.ts
│ └── use-feature-b.ts
├── components/
│ ├── header/
│ │ └── index.tsx
│ ├── content/
│ │ └── index.tsx
│ └── modals/
│ └── index.tsx
└── utils/
└── helpers.ts
```
### Pattern C: Feature-Based Structure (Dify Standard)
Following Dify's existing patterns:
```
configuration/
├── index.tsx # Main page component
├── base/ # Base/shared components
│ ├── feature-panel/
│ ├── group-name/
│ └── operation-btn/
├── config/ # Config section
│ ├── index.tsx
│ ├── agent/
│ └── automatic/
├── dataset-config/ # Dataset section
│ ├── index.tsx
│ ├── card-item/
│ └── params-config/
├── debug/ # Debug section
│ ├── index.tsx
│ └── hooks.tsx
└── hooks/ # Shared hooks
└── use-advanced-prompt-config.ts
```
## Props Design
### Minimal Props Principle
Pass only what's needed:
```typescript
// ❌ Bad: Passing entire objects when only some fields needed
<ConfigHeader appDetail={appDetail} modelConfig={modelConfig} />
// ✅ Good: Destructure to minimum required
<ConfigHeader
appName={appDetail.name}
isAdvancedMode={modelConfig.isAdvanced}
onPublish={handlePublish}
/>
```
### Callback Props Pattern
Use callbacks for child-to-parent communication:
```typescript
// Parent
const Parent = () => {
const [value, setValue] = useState('')
return (
<Child
value={value}
onChange={setValue}
onSubmit={handleSubmit}
/>
)
}
// Child
interface ChildProps {
value: string
onChange: (value: string) => void
onSubmit: () => void
}
const Child: FC<ChildProps> = ({ value, onChange, onSubmit }) => {
return (
<div>
<input value={value} onChange={e => onChange(e.target.value)} />
<button onClick={onSubmit}>Submit</button>
</div>
)
}
```
### Render Props for Flexibility
When sub-components need parent context:
```typescript
interface ListProps<T> {
items: T[]
renderItem: (item: T, index: number) => React.ReactNode
renderEmpty?: () => React.ReactNode
}
function List<T>({ items, renderItem, renderEmpty }: ListProps<T>) {
if (items.length === 0 && renderEmpty) {
return <>{renderEmpty()}</>
}
return (
<div>
{items.map((item, index) => renderItem(item, index))}
</div>
)
}
// Usage
<List
items={operations}
renderItem={(op, i) => <OperationItem key={i} operation={op} />}
renderEmpty={() => <EmptyState message="No operations" />}
/>
```

View File

@ -0,0 +1,317 @@
# Hook Extraction Patterns
This document provides detailed guidance on extracting custom hooks from complex components in Dify.
## When to Extract Hooks
Extract a custom hook when you identify:
1. **Coupled state groups** - Multiple `useState` hooks that are always used together
1. **Complex effects** - `useEffect` with multiple dependencies or cleanup logic
1. **Business logic** - Data transformations, validations, or calculations
1. **Reusable patterns** - Logic that appears in multiple components
## Extraction Process
### Step 1: Identify State Groups
Look for state variables that are logically related:
```typescript
// ❌ These belong together - extract to hook
const [modelConfig, setModelConfig] = useState<ModelConfig>(...)
const [completionParams, setCompletionParams] = useState<FormValue>({})
const [modelModeType, setModelModeType] = useState<ModelModeType>(...)
// These are model-related state that should be in useModelConfig()
```
### Step 2: Identify Related Effects
Find effects that modify the grouped state:
```typescript
// ❌ These effects belong with the state above
useEffect(() => {
if (hasFetchedDetail && !modelModeType) {
const mode = currModel?.model_properties.mode
if (mode) {
const newModelConfig = produce(modelConfig, (draft) => {
draft.mode = mode
})
setModelConfig(newModelConfig)
}
}
}, [textGenerationModelList, hasFetchedDetail, modelModeType, currModel])
```
### Step 3: Create the Hook
```typescript
// hooks/use-model-config.ts
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { ModelConfig } from '@/models/debug'
import { produce } from 'immer'
import { useEffect, useState } from 'react'
import { ModelModeType } from '@/types/app'
interface UseModelConfigParams {
initialConfig?: Partial<ModelConfig>
currModel?: { model_properties?: { mode?: ModelModeType } }
hasFetchedDetail: boolean
}
interface UseModelConfigReturn {
modelConfig: ModelConfig
setModelConfig: (config: ModelConfig) => void
completionParams: FormValue
setCompletionParams: (params: FormValue) => void
modelModeType: ModelModeType
}
export const useModelConfig = ({
initialConfig,
currModel,
hasFetchedDetail,
}: UseModelConfigParams): UseModelConfigReturn => {
const [modelConfig, setModelConfig] = useState<ModelConfig>({
provider: 'langgenius/openai/openai',
model_id: 'gpt-3.5-turbo',
mode: ModelModeType.unset,
// ... default values
...initialConfig,
})
const [completionParams, setCompletionParams] = useState<FormValue>({})
const modelModeType = modelConfig.mode
// Fill old app data missing model mode
useEffect(() => {
if (hasFetchedDetail && !modelModeType) {
const mode = currModel?.model_properties?.mode
if (mode) {
setModelConfig(produce(modelConfig, (draft) => {
draft.mode = mode
}))
}
}
}, [hasFetchedDetail, modelModeType, currModel])
return {
modelConfig,
setModelConfig,
completionParams,
setCompletionParams,
modelModeType,
}
}
```
### Step 4: Update Component
```typescript
// Before: 50+ lines of state management
const Configuration: FC = () => {
const [modelConfig, setModelConfig] = useState<ModelConfig>(...)
// ... lots of related state and effects
}
// After: Clean component
const Configuration: FC = () => {
const {
modelConfig,
setModelConfig,
completionParams,
setCompletionParams,
modelModeType,
} = useModelConfig({
currModel,
hasFetchedDetail,
})
// Component now focuses on UI
}
```
## Naming Conventions
### Hook Names
- Use `use` prefix: `useModelConfig`, `useDatasetConfig`
- Be specific: `useAdvancedPromptConfig` not `usePrompt`
- Include domain: `useWorkflowVariables`, `useMCPServer`
### File Names
- Kebab-case: `use-model-config.ts`
- Place in `hooks/` subdirectory when multiple hooks exist
- Place alongside component for single-use hooks
### Return Type Names
- Suffix with `Return`: `UseModelConfigReturn`
- Suffix params with `Params`: `UseModelConfigParams`
## Common Hook Patterns in Dify
### 1. Data Fetching Hook (React Query)
```typescript
// Pattern: Use @tanstack/react-query for data fetching
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { get } from '@/service/base'
import { useInvalid } from '@/service/use-base'
const NAME_SPACE = 'appConfig'
// Query keys for cache management
export const appConfigQueryKeys = {
detail: (appId: string) => [NAME_SPACE, 'detail', appId] as const,
}
// Main data hook
export const useAppConfig = (appId: string) => {
return useQuery({
enabled: !!appId,
queryKey: appConfigQueryKeys.detail(appId),
queryFn: () => get<AppDetailResponse>(`/apps/${appId}`),
select: data => data?.model_config || null,
})
}
// Invalidation hook for refreshing data
export const useInvalidAppConfig = () => {
return useInvalid([NAME_SPACE])
}
// Usage in component
const Component = () => {
const { data: config, isLoading, error, refetch } = useAppConfig(appId)
const invalidAppConfig = useInvalidAppConfig()
const handleRefresh = () => {
invalidAppConfig() // Invalidates cache and triggers refetch
}
return <div>...</div>
}
```
### 2. Form State Hook
```typescript
// Pattern: Form state + validation + submission
export const useConfigForm = (initialValues: ConfigFormValues) => {
const [values, setValues] = useState(initialValues)
const [errors, setErrors] = useState<Record<string, string>>({})
const [isSubmitting, setIsSubmitting] = useState(false)
const validate = useCallback(() => {
const newErrors: Record<string, string> = {}
if (!values.name) newErrors.name = 'Name is required'
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}, [values])
const handleChange = useCallback((field: string, value: any) => {
setValues(prev => ({ ...prev, [field]: value }))
}, [])
const handleSubmit = useCallback(async (onSubmit: (values: ConfigFormValues) => Promise<void>) => {
if (!validate()) return
setIsSubmitting(true)
try {
await onSubmit(values)
} finally {
setIsSubmitting(false)
}
}, [values, validate])
return { values, errors, isSubmitting, handleChange, handleSubmit }
}
```
### 3. Modal State Hook
```typescript
// Pattern: Multiple modal management
type ModalType = 'edit' | 'delete' | 'duplicate' | null
export const useModalState = () => {
const [activeModal, setActiveModal] = useState<ModalType>(null)
const [modalData, setModalData] = useState<any>(null)
const openModal = useCallback((type: ModalType, data?: any) => {
setActiveModal(type)
setModalData(data)
}, [])
const closeModal = useCallback(() => {
setActiveModal(null)
setModalData(null)
}, [])
return {
activeModal,
modalData,
openModal,
closeModal,
isOpen: useCallback((type: ModalType) => activeModal === type, [activeModal]),
}
}
```
### 4. Toggle/Boolean Hook
```typescript
// Pattern: Boolean state with convenience methods
export const useToggle = (initialValue = false) => {
const [value, setValue] = useState(initialValue)
const toggle = useCallback(() => setValue(v => !v), [])
const setTrue = useCallback(() => setValue(true), [])
const setFalse = useCallback(() => setValue(false), [])
return [value, { toggle, setTrue, setFalse, set: setValue }] as const
}
// Usage
const [isExpanded, { toggle, setTrue: expand, setFalse: collapse }] = useToggle()
```
## Testing Extracted Hooks
After extraction, test hooks in isolation:
```typescript
// use-model-config.spec.ts
import { renderHook, act } from '@testing-library/react'
import { useModelConfig } from './use-model-config'
describe('useModelConfig', () => {
it('should initialize with default values', () => {
const { result } = renderHook(() => useModelConfig({
hasFetchedDetail: false,
}))
expect(result.current.modelConfig.provider).toBe('langgenius/openai/openai')
expect(result.current.modelModeType).toBe(ModelModeType.unset)
})
it('should update model config', () => {
const { result } = renderHook(() => useModelConfig({
hasFetchedDetail: true,
}))
act(() => {
result.current.setModelConfig({
...result.current.modelConfig,
model_id: 'gpt-4',
})
})
expect(result.current.modelConfig.model_id).toBe('gpt-4')
})
})
```

View File

@ -318,5 +318,5 @@ For more detailed information, refer to:
- `web/vitest.config.ts` - Vitest configuration
- `web/vitest.setup.ts` - Test environment setup
- `web/testing/analyze-component.js` - Component analysis tool
- `web/scripts/analyze-component.js` - Component analysis tool
- Modules are not mocked automatically. Global mocks live in `web/vitest.setup.ts` (for example `react-i18next`, `next/image`); mock other modules like `ky` or `mime` locally in test files.

View File

@ -13,12 +13,28 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check Docker Compose inputs
id: docker-compose-changes
uses: tj-actions/changed-files@v46
with:
files: |
docker/generate_docker_compose
docker/.env.example
docker/docker-compose-template.yaml
docker/docker-compose.yaml
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- uses: astral-sh/setup-uv@v6
- name: Generate Docker Compose
if: steps.docker-compose-changes.outputs.any_changed == 'true'
run: |
cd docker
./generate_docker_compose
- run: |
cd api
uv sync --dev

View File

@ -108,36 +108,6 @@ jobs:
working-directory: ./web
run: pnpm run type-check:tsgo
docker-compose-template:
name: Docker Compose Template
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Check changed files
id: changed-files
uses: tj-actions/changed-files@v46
with:
files: |
docker/generate_docker_compose
docker/.env.example
docker/docker-compose-template.yaml
docker/docker-compose.yaml
- name: Generate Docker Compose
if: steps.changed-files.outputs.any_changed == 'true'
run: |
cd docker
./generate_docker_compose
- name: Check for changes
if: steps.changed-files.outputs.any_changed == 'true'
run: git diff --exit-code
superlinter:
name: SuperLinter
runs-on: ubuntu-latest

View File

@ -0,0 +1,57 @@
import os
from email.message import Message
from urllib.parse import quote
from flask import Response
HTML_MIME_TYPES = frozenset({"text/html", "application/xhtml+xml"})
HTML_EXTENSIONS = frozenset({"html", "htm"})
def _normalize_mime_type(mime_type: str | None) -> str:
if not mime_type:
return ""
message = Message()
message["Content-Type"] = mime_type
return message.get_content_type().strip().lower()
def _is_html_extension(extension: str | None) -> bool:
if not extension:
return False
return extension.lstrip(".").lower() in HTML_EXTENSIONS
def is_html_content(mime_type: str | None, filename: str | None, extension: str | None = None) -> bool:
normalized_mime_type = _normalize_mime_type(mime_type)
if normalized_mime_type in HTML_MIME_TYPES:
return True
if _is_html_extension(extension):
return True
if filename:
return _is_html_extension(os.path.splitext(filename)[1])
return False
def enforce_download_for_html(
response: Response,
*,
mime_type: str | None,
filename: str | None,
extension: str | None = None,
) -> bool:
if not is_html_content(mime_type, filename, extension):
return False
if filename:
encoded_filename = quote(filename)
response.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{encoded_filename}"
else:
response.headers["Content-Disposition"] = "attachment"
response.headers["Content-Type"] = "application/octet-stream"
response.headers["X-Content-Type-Options"] = "nosniff"
return True

View File

@ -1,8 +1,9 @@
import base64
from typing import Literal
from flask import request
from flask_restx import Resource, fields
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, Field
from werkzeug.exceptions import BadRequest
from controllers.console import console_ns
@ -15,22 +16,8 @@ DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
class SubscriptionQuery(BaseModel):
plan: str = Field(..., description="Subscription plan")
interval: str = Field(..., description="Billing interval")
@field_validator("plan")
@classmethod
def validate_plan(cls, value: str) -> str:
if value not in [CloudPlan.PROFESSIONAL, CloudPlan.TEAM]:
raise ValueError("Invalid plan")
return value
@field_validator("interval")
@classmethod
def validate_interval(cls, value: str) -> str:
if value not in {"month", "year"}:
raise ValueError("Invalid interval")
return value
plan: Literal[CloudPlan.PROFESSIONAL, CloudPlan.TEAM] = Field(..., description="Subscription plan")
interval: Literal["month", "year"] = Field(..., description="Billing interval")
class PartnerTenantsPayload(BaseModel):

View File

@ -1,6 +1,5 @@
import logging
from typing import Literal
from uuid import UUID
from flask import request
from flask_restx import marshal_with
@ -26,6 +25,7 @@ from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotIni
from core.model_runtime.errors.invoke import InvokeError
from fields.message_fields import message_infinite_scroll_pagination_fields
from libs import helper
from libs.helper import UUIDStrOrEmpty
from libs.login import current_account_with_tenant
from models.model import AppMode
from services.app_generate_service import AppGenerateService
@ -44,8 +44,8 @@ logger = logging.getLogger(__name__)
class MessageListQuery(BaseModel):
conversation_id: UUID
first_id: UUID | None = None
conversation_id: UUIDStrOrEmpty
first_id: UUIDStrOrEmpty | None = None
limit: int = Field(default=20, ge=1, le=100)

View File

@ -1,5 +1,3 @@
from uuid import UUID
from flask import request
from flask_restx import fields, marshal_with
from pydantic import BaseModel, Field
@ -10,19 +8,19 @@ from controllers.console import console_ns
from controllers.console.explore.error import NotCompletionAppError
from controllers.console.explore.wraps import InstalledAppResource
from fields.conversation_fields import message_file_fields
from libs.helper import TimestampField
from libs.helper import TimestampField, UUIDStrOrEmpty
from libs.login import current_account_with_tenant
from services.errors.message import MessageNotExistsError
from services.saved_message_service import SavedMessageService
class SavedMessageListQuery(BaseModel):
last_id: UUID | None = None
last_id: UUIDStrOrEmpty | None = None
limit: int = Field(default=20, ge=1, le=100)
class SavedMessageCreatePayload(BaseModel):
message_id: UUID
message_id: UUIDStrOrEmpty
register_schema_models(console_ns, SavedMessageListQuery, SavedMessageCreatePayload)

View File

@ -1,6 +1,8 @@
from flask_restx import Resource, reqparse
from flask_restx import Resource
from pydantic import BaseModel
from werkzeug.exceptions import Forbidden
from controllers.common.schema import register_schema_models
from controllers.console import console_ns
from controllers.console.wraps import account_initialization_required, setup_required
from core.model_runtime.entities.model_entities import ModelType
@ -10,10 +12,20 @@ from models import TenantAccountRole
from services.model_load_balancing_service import ModelLoadBalancingService
class LoadBalancingCredentialPayload(BaseModel):
model: str
model_type: ModelType
credentials: dict[str, object]
register_schema_models(console_ns, LoadBalancingCredentialPayload)
@console_ns.route(
"/workspaces/current/model-providers/<path:provider>/models/load-balancing-configs/credentials-validate"
)
class LoadBalancingCredentialsValidateApi(Resource):
@console_ns.expect(console_ns.models[LoadBalancingCredentialPayload.__name__])
@setup_required
@login_required
@account_initialization_required
@ -24,20 +36,7 @@ class LoadBalancingCredentialsValidateApi(Resource):
tenant_id = current_tenant_id
parser = (
reqparse.RequestParser()
.add_argument("model", type=str, required=True, nullable=False, location="json")
.add_argument(
"model_type",
type=str,
required=True,
nullable=False,
choices=[mt.value for mt in ModelType],
location="json",
)
.add_argument("credentials", type=dict, required=True, nullable=False, location="json")
)
args = parser.parse_args()
payload = LoadBalancingCredentialPayload.model_validate(console_ns.payload or {})
# validate model load balancing credentials
model_load_balancing_service = ModelLoadBalancingService()
@ -49,9 +48,9 @@ class LoadBalancingCredentialsValidateApi(Resource):
model_load_balancing_service.validate_load_balancing_credentials(
tenant_id=tenant_id,
provider=provider,
model=args["model"],
model_type=args["model_type"],
credentials=args["credentials"],
model=payload.model,
model_type=payload.model_type,
credentials=payload.credentials,
)
except CredentialsValidateFailedError as ex:
result = False
@ -69,6 +68,7 @@ class LoadBalancingCredentialsValidateApi(Resource):
"/workspaces/current/model-providers/<path:provider>/models/load-balancing-configs/<string:config_id>/credentials-validate"
)
class LoadBalancingConfigCredentialsValidateApi(Resource):
@console_ns.expect(console_ns.models[LoadBalancingCredentialPayload.__name__])
@setup_required
@login_required
@account_initialization_required
@ -79,20 +79,7 @@ class LoadBalancingConfigCredentialsValidateApi(Resource):
tenant_id = current_tenant_id
parser = (
reqparse.RequestParser()
.add_argument("model", type=str, required=True, nullable=False, location="json")
.add_argument(
"model_type",
type=str,
required=True,
nullable=False,
choices=[mt.value for mt in ModelType],
location="json",
)
.add_argument("credentials", type=dict, required=True, nullable=False, location="json")
)
args = parser.parse_args()
payload = LoadBalancingCredentialPayload.model_validate(console_ns.payload or {})
# validate model load balancing config credentials
model_load_balancing_service = ModelLoadBalancingService()
@ -104,9 +91,9 @@ class LoadBalancingConfigCredentialsValidateApi(Resource):
model_load_balancing_service.validate_load_balancing_credentials(
tenant_id=tenant_id,
provider=provider,
model=args["model"],
model_type=args["model_type"],
credentials=args["credentials"],
model=payload.model,
model_type=payload.model_type,
credentials=payload.credentials,
config_id=config_id,
)
except CredentialsValidateFailedError as ex:

View File

@ -1,4 +1,5 @@
import io
import logging
from urllib.parse import urlparse
from flask import make_response, redirect, request, send_file
@ -17,6 +18,7 @@ from controllers.console.wraps import (
is_admin_or_owner_required,
setup_required,
)
from core.db.session_factory import session_factory
from core.entities.mcp_provider import MCPAuthentication, MCPConfiguration
from core.helper.tool_provider_cache import ToolProviderListCache
from core.mcp.auth.auth_flow import auth, handle_callback
@ -40,6 +42,8 @@ from services.tools.tools_manage_service import ToolCommonService
from services.tools.tools_transform_service import ToolTransformService
from services.tools.workflow_tools_manage_service import WorkflowToolManageService
logger = logging.getLogger(__name__)
def is_valid_url(url: str) -> bool:
if not url:
@ -945,8 +949,8 @@ class ToolProviderMCPApi(Resource):
configuration = MCPConfiguration.model_validate(args["configuration"])
authentication = MCPAuthentication.model_validate(args["authentication"]) if args["authentication"] else None
# Create provider in transaction
with Session(db.engine) as session, session.begin():
# 1) Create provider in a short transaction (no network I/O inside)
with session_factory.create_session() as session, session.begin():
service = MCPToolManageService(session=session)
result = service.create_provider(
tenant_id=tenant_id,
@ -962,7 +966,28 @@ class ToolProviderMCPApi(Resource):
authentication=authentication,
)
# Invalidate cache AFTER transaction commits to avoid holding locks during Redis operations
# 2) Try to fetch tools immediately after creation so they appear without a second save.
# Perform network I/O outside any DB session to avoid holding locks.
try:
reconnect = MCPToolManageService.reconnect_with_url(
server_url=args["server_url"],
headers=args.get("headers") or {},
timeout=configuration.timeout,
sse_read_timeout=configuration.sse_read_timeout,
)
# Update just-created provider with authed/tools in a new short transaction
with session_factory.create_session() as session, session.begin():
service = MCPToolManageService(session=session)
db_provider = service.get_provider(provider_id=result.id, tenant_id=tenant_id)
db_provider.authed = reconnect.authed
db_provider.tools = reconnect.tools
result = ToolTransformService.mcp_provider_to_user_provider(db_provider, for_list=True)
except Exception:
# Best-effort: if initial fetch fails (e.g., auth required), return created provider as-is
logger.warning("Failed to fetch MCP tools after creation", exc_info=True)
# Final cache invalidation to ensure list views are up to date
ToolProviderListCache.invalidate_cache(tenant_id)
return jsonable_encoder(result)

View File

@ -7,6 +7,7 @@ from werkzeug.exceptions import NotFound
import services
from controllers.common.errors import UnsupportedFileTypeError
from controllers.common.file_response import enforce_download_for_html
from controllers.files import files_ns
from extensions.ext_database import db
from services.account_service import TenantService
@ -138,6 +139,13 @@ class FilePreviewApi(Resource):
response.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{encoded_filename}"
response.headers["Content-Type"] = "application/octet-stream"
enforce_download_for_html(
response,
mime_type=upload_file.mime_type,
filename=upload_file.name,
extension=upload_file.extension,
)
return response

View File

@ -6,6 +6,7 @@ from pydantic import BaseModel, Field
from werkzeug.exceptions import Forbidden, NotFound
from controllers.common.errors import UnsupportedFileTypeError
from controllers.common.file_response import enforce_download_for_html
from controllers.files import files_ns
from core.tools.signature import verify_tool_file_signature
from core.tools.tool_file_manager import ToolFileManager
@ -78,4 +79,11 @@ class ToolFileApi(Resource):
encoded_filename = quote(tool_file.name)
response.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{encoded_filename}"
enforce_download_for_html(
response,
mime_type=tool_file.mimetype,
filename=tool_file.name,
extension=extension,
)
return response

View File

@ -5,6 +5,7 @@ from flask import Response, request
from flask_restx import Resource
from pydantic import BaseModel, Field
from controllers.common.file_response import enforce_download_for_html
from controllers.common.schema import register_schema_model
from controllers.service_api import service_api_ns
from controllers.service_api.app.error import (
@ -183,6 +184,13 @@ class FilePreviewApi(Resource):
# Override content-type for downloads to force download
response.headers["Content-Type"] = "application/octet-stream"
enforce_download_for_html(
response,
mime_type=upload_file.mime_type,
filename=upload_file.name,
extension=upload_file.extension,
)
# Add caching headers for performance
response.headers["Cache-Control"] = "public, max-age=3600" # Cache for 1 hour

View File

@ -13,7 +13,6 @@ from controllers.service_api.dataset.error import DatasetInUseError, DatasetName
from controllers.service_api.wraps import (
DatasetApiResource,
cloud_edition_billing_rate_limit_check,
validate_dataset_token,
)
from core.model_runtime.entities.model_entities import ModelType
from core.provider_manager import ProviderManager
@ -460,9 +459,8 @@ class DatasetTagsApi(DatasetApiResource):
401: "Unauthorized - invalid API token",
}
)
@validate_dataset_token
@service_api_ns.marshal_with(build_dataset_tag_fields(service_api_ns))
def get(self, _, dataset_id):
def get(self, _):
"""Get all knowledge type tags."""
assert isinstance(current_user, Account)
cid = current_user.current_tenant_id
@ -482,8 +480,7 @@ class DatasetTagsApi(DatasetApiResource):
}
)
@service_api_ns.marshal_with(build_dataset_tag_fields(service_api_ns))
@validate_dataset_token
def post(self, _, dataset_id):
def post(self, _):
"""Add a knowledge type tag."""
assert isinstance(current_user, Account)
if not (current_user.has_edit_permission or current_user.is_dataset_editor):
@ -506,8 +503,7 @@ class DatasetTagsApi(DatasetApiResource):
}
)
@service_api_ns.marshal_with(build_dataset_tag_fields(service_api_ns))
@validate_dataset_token
def patch(self, _, dataset_id):
def patch(self, _):
assert isinstance(current_user, Account)
if not (current_user.has_edit_permission or current_user.is_dataset_editor):
raise Forbidden()
@ -533,9 +529,8 @@ class DatasetTagsApi(DatasetApiResource):
403: "Forbidden - insufficient permissions",
}
)
@validate_dataset_token
@edit_permission_required
def delete(self, _, dataset_id):
def delete(self, _):
"""Delete a knowledge type tag."""
payload = TagDeletePayload.model_validate(service_api_ns.payload or {})
TagService.delete_tag(payload.tag_id)
@ -555,8 +550,7 @@ class DatasetTagBindingApi(DatasetApiResource):
403: "Forbidden - insufficient permissions",
}
)
@validate_dataset_token
def post(self, _, dataset_id):
def post(self, _):
# The role of the current user in the ta table must be admin, owner, editor, or dataset_operator
assert isinstance(current_user, Account)
if not (current_user.has_edit_permission or current_user.is_dataset_editor):
@ -580,8 +574,7 @@ class DatasetTagUnbindingApi(DatasetApiResource):
403: "Forbidden - insufficient permissions",
}
)
@validate_dataset_token
def post(self, _, dataset_id):
def post(self, _):
# The role of the current user in the ta table must be admin, owner, editor, or dataset_operator
assert isinstance(current_user, Account)
if not (current_user.has_edit_permission or current_user.is_dataset_editor):
@ -604,7 +597,6 @@ class DatasetTagsBindingStatusApi(DatasetApiResource):
401: "Unauthorized - invalid API token",
}
)
@validate_dataset_token
def get(self, _, *args, **kwargs):
"""Get all knowledge type tags."""
dataset_id = kwargs.get("dataset_id")

View File

@ -1,6 +1,6 @@
import json
import logging
from typing import Any
from typing import Any, cast
from core.tools.entities.api_entities import ToolProviderTypeApiLiteral
from extensions.ext_redis import redis_client, redis_fallback
@ -50,7 +50,9 @@ class ToolProviderListCache:
redis_client.delete(cache_key)
else:
# Invalidate all caches for this tenant
pattern = f"tool_providers:tenant_id:{tenant_id}:*"
keys = list(redis_client.scan_iter(pattern))
if keys:
redis_client.delete(*keys)
keys = ["builtin", "model", "api", "workflow", "mcp"]
pipeline = redis_client.pipeline()
for key in keys:
cache_key = ToolProviderListCache._generate_cache_key(tenant_id, cast(ToolProviderTypeApiLiteral, key))
pipeline.delete(cache_key)
pipeline.execute()

View File

@ -76,7 +76,7 @@ class PluginParameter(BaseModel):
auto_generate: PluginParameterAutoGenerate | None = None
template: PluginParameterTemplate | None = None
required: bool = False
default: Union[float, int, str, bool] | None = None
default: Union[float, int, str, bool, list, dict] | None = None
min: Union[float, int] | None = None
max: Union[float, int] | None = None
precision: int | None = None

View File

@ -5,6 +5,7 @@ from sqlalchemy.orm import Session
from core.app.app_config.entities import VariableEntity, VariableEntityType
from core.app.apps.workflow.app_config_manager import WorkflowAppConfigManager
from core.db.session_factory import session_factory
from core.plugin.entities.parameters import PluginParameterOption
from core.tools.__base.tool_provider import ToolProviderController
from core.tools.__base.tool_runtime import ToolRuntime
@ -47,33 +48,30 @@ class WorkflowToolProviderController(ToolProviderController):
@classmethod
def from_db(cls, db_provider: WorkflowToolProvider) -> "WorkflowToolProviderController":
with Session(db.engine, expire_on_commit=False) as session, session.begin():
provider = session.get(WorkflowToolProvider, db_provider.id) if db_provider.id else None
if not provider:
raise ValueError("workflow provider not found")
app = session.get(App, provider.app_id)
with session_factory.create_session() as session, session.begin():
app = session.get(App, db_provider.app_id)
if not app:
raise ValueError("app not found")
user = session.get(Account, provider.user_id) if provider.user_id else None
user = session.get(Account, db_provider.user_id) if db_provider.user_id else None
controller = WorkflowToolProviderController(
entity=ToolProviderEntity(
identity=ToolProviderIdentity(
author=user.name if user else "",
name=provider.label,
label=I18nObject(en_US=provider.label, zh_Hans=provider.label),
description=I18nObject(en_US=provider.description, zh_Hans=provider.description),
icon=provider.icon,
name=db_provider.label,
label=I18nObject(en_US=db_provider.label, zh_Hans=db_provider.label),
description=I18nObject(en_US=db_provider.description, zh_Hans=db_provider.description),
icon=db_provider.icon,
),
credentials_schema=[],
plugin_id=None,
),
provider_id=provider.id or "",
provider_id="",
)
controller.tools = [
controller._get_db_provider_tool(provider, app, session=session, user=user),
controller._get_db_provider_tool(db_provider, app, session=session, user=user),
]
return controller

View File

@ -1,6 +1,6 @@
[project]
name = "dify-api"
version = "1.11.1"
version = "1.11.2"
requires-python = ">=3.11,<3.13"
dependencies = [

View File

@ -3458,7 +3458,7 @@ class SegmentService:
if keyword:
query = query.where(DocumentSegment.content.ilike(f"%{keyword}%"))
query = query.order_by(DocumentSegment.position.asc())
query = query.order_by(DocumentSegment.position.asc(), DocumentSegment.id.asc())
paginated_segments = db.paginate(select=query, page=page, per_page=limit, max_per_page=100, error_out=False)
return paginated_segments.items, paginated_segments.total

View File

@ -286,12 +286,12 @@ class BuiltinToolManageService:
session.add(db_provider)
session.commit()
# Invalidate tool providers cache
ToolProviderListCache.invalidate_cache(tenant_id)
except Exception as e:
session.rollback()
raise ValueError(str(e))
# Invalidate tool providers cache
ToolProviderListCache.invalidate_cache(tenant_id, "builtin")
return {"result": "success"}
@staticmethod

View File

@ -319,8 +319,14 @@ class MCPToolManageService:
except MCPError as e:
raise ValueError(f"Failed to connect to MCP server: {e}")
# Update database with retrieved tools
db_provider.tools = json.dumps([tool.model_dump() for tool in tools])
# Update database with retrieved tools (ensure description is a non-null string)
tools_payload = []
for tool in tools:
data = tool.model_dump()
if data.get("description") is None:
data["description"] = ""
tools_payload.append(data)
db_provider.tools = json.dumps(tools_payload)
db_provider.authed = True
db_provider.updated_at = datetime.now()
self._session.flush()
@ -620,6 +626,21 @@ class MCPToolManageService:
server_url_hash=new_server_url_hash,
)
@staticmethod
def reconnect_with_url(
*,
server_url: str,
headers: dict[str, str],
timeout: float | None,
sse_read_timeout: float | None,
) -> ReconnectResult:
return MCPToolManageService._reconnect_with_url(
server_url=server_url,
headers=headers,
timeout=timeout,
sse_read_timeout=sse_read_timeout,
)
@staticmethod
def _reconnect_with_url(
*,
@ -642,9 +663,16 @@ class MCPToolManageService:
sse_read_timeout=sse_read_timeout,
) as mcp_client:
tools = mcp_client.list_tools()
# Ensure tool descriptions are non-null in payload
tools_payload = []
for t in tools:
d = t.model_dump()
if d.get("description") is None:
d["description"] = ""
tools_payload.append(d)
return ReconnectResult(
authed=True,
tools=json.dumps([tool.model_dump() for tool in tools]),
tools=json.dumps(tools_payload),
encrypted_credentials=EMPTY_CREDENTIALS_JSON,
)
except MCPAuthError:

View File

@ -5,8 +5,8 @@ from datetime import datetime
from typing import Any
from sqlalchemy import or_, select
from sqlalchemy.orm import Session
from core.db.session_factory import session_factory
from core.helper.tool_provider_cache import ToolProviderListCache
from core.model_runtime.utils.encoders import jsonable_encoder
from core.tools.__base.tool_provider import ToolProviderController
@ -68,26 +68,27 @@ class WorkflowToolManageService:
if workflow is None:
raise ValueError(f"Workflow not found for app {workflow_app_id}")
with Session(db.engine, expire_on_commit=False) as session, session.begin():
workflow_tool_provider = WorkflowToolProvider(
tenant_id=tenant_id,
user_id=user_id,
app_id=workflow_app_id,
name=name,
label=label,
icon=json.dumps(icon),
description=description,
parameter_configuration=json.dumps(parameters),
privacy_policy=privacy_policy,
version=workflow.version,
)
session.add(workflow_tool_provider)
workflow_tool_provider = WorkflowToolProvider(
tenant_id=tenant_id,
user_id=user_id,
app_id=workflow_app_id,
name=name,
label=label,
icon=json.dumps(icon),
description=description,
parameter_configuration=json.dumps(parameters),
privacy_policy=privacy_policy,
version=workflow.version,
)
try:
WorkflowToolProviderController.from_db(workflow_tool_provider)
except Exception as e:
raise ValueError(str(e))
with session_factory.create_session() as session, session.begin():
session.add(workflow_tool_provider)
if labels is not None:
ToolLabelManager.update_tool_labels(
ToolTransformService.workflow_provider_to_controller(workflow_tool_provider), labels

View File

@ -705,3 +705,207 @@ class TestWorkflowToolManageService:
db.session.refresh(created_tool)
assert created_tool.name == first_tool_name
assert created_tool.updated_at is not None
def test_create_workflow_tool_with_file_parameter_default(
self, db_session_with_containers, mock_external_service_dependencies
):
"""
Test workflow tool creation with FILE parameter having a file object as default.
This test verifies:
- FILE parameters can have file object defaults
- The default value (dict with id/base64Url) is properly handled
- Tool creation succeeds without Pydantic validation errors
Related issue: Array[File] default value causes Pydantic validation errors.
"""
fake = Faker()
# Create test data
app, account, workflow = self._create_test_app_and_account(
db_session_with_containers, mock_external_service_dependencies
)
# Create workflow graph with a FILE variable that has a default value
workflow_graph = {
"nodes": [
{
"id": "start_node",
"data": {
"type": "start",
"variables": [
{
"variable": "document",
"label": "Document",
"type": "file",
"required": False,
"default": {"id": fake.uuid4(), "base64Url": ""},
}
],
},
}
]
}
workflow.graph = json.dumps(workflow_graph)
# Setup workflow tool parameters with FILE type
file_parameters = [
{
"name": "document",
"description": "Upload a document",
"form": "form",
"type": "file",
"required": False,
}
]
# Execute the method under test
# Note: from_db is mocked, so this test primarily validates the parameter configuration
result = WorkflowToolManageService.create_workflow_tool(
user_id=account.id,
tenant_id=account.current_tenant.id,
workflow_app_id=app.id,
name=fake.word(),
label=fake.word(),
icon={"type": "emoji", "emoji": "📄"},
description=fake.text(max_nb_chars=200),
parameters=file_parameters,
)
# Verify the result
assert result == {"result": "success"}
def test_create_workflow_tool_with_files_parameter_default(
self, db_session_with_containers, mock_external_service_dependencies
):
"""
Test workflow tool creation with FILES (Array[File]) parameter having file objects as default.
This test verifies:
- FILES parameters can have a list of file objects as default
- The default value (list of dicts with id/base64Url) is properly handled
- Tool creation succeeds without Pydantic validation errors
Related issue: Array[File] default value causes 4 Pydantic validation errors
because PluginParameter.default only accepts Union[float, int, str, bool] | None.
"""
fake = Faker()
# Create test data
app, account, workflow = self._create_test_app_and_account(
db_session_with_containers, mock_external_service_dependencies
)
# Create workflow graph with a FILE_LIST variable that has a default value
workflow_graph = {
"nodes": [
{
"id": "start_node",
"data": {
"type": "start",
"variables": [
{
"variable": "documents",
"label": "Documents",
"type": "file-list",
"required": False,
"default": [
{"id": fake.uuid4(), "base64Url": ""},
{"id": fake.uuid4(), "base64Url": ""},
],
}
],
},
}
]
}
workflow.graph = json.dumps(workflow_graph)
# Setup workflow tool parameters with FILES type
files_parameters = [
{
"name": "documents",
"description": "Upload multiple documents",
"form": "form",
"type": "files",
"required": False,
}
]
# Execute the method under test
# Note: from_db is mocked, so this test primarily validates the parameter configuration
result = WorkflowToolManageService.create_workflow_tool(
user_id=account.id,
tenant_id=account.current_tenant.id,
workflow_app_id=app.id,
name=fake.word(),
label=fake.word(),
icon={"type": "emoji", "emoji": "📁"},
description=fake.text(max_nb_chars=200),
parameters=files_parameters,
)
# Verify the result
assert result == {"result": "success"}
def test_create_workflow_tool_db_commit_before_validation(
self, db_session_with_containers, mock_external_service_dependencies
):
"""
Test that database commit happens before validation, causing DB pollution on validation failure.
This test verifies the second bug:
- WorkflowToolProvider is committed to database BEFORE from_db validation
- If validation fails, the record remains in the database
- Subsequent attempts fail with "Tool already exists" error
This demonstrates why we need to validate BEFORE database commit.
"""
fake = Faker()
# Create test data
app, account, workflow = self._create_test_app_and_account(
db_session_with_containers, mock_external_service_dependencies
)
tool_name = fake.word()
# Mock from_db to raise validation error
mock_external_service_dependencies["workflow_tool_provider_controller"].from_db.side_effect = ValueError(
"Validation failed: default parameter type mismatch"
)
# Attempt to create workflow tool (will fail at validation stage)
with pytest.raises(ValueError) as exc_info:
WorkflowToolManageService.create_workflow_tool(
user_id=account.id,
tenant_id=account.current_tenant.id,
workflow_app_id=app.id,
name=tool_name,
label=fake.word(),
icon={"type": "emoji", "emoji": "🔧"},
description=fake.text(max_nb_chars=200),
parameters=self._create_test_workflow_tool_parameters(),
)
assert "Validation failed" in str(exc_info.value)
# Verify the tool was NOT created in database
# This is the expected behavior (no pollution)
from extensions.ext_database import db
tool_count = (
db.session.query(WorkflowToolProvider)
.where(
WorkflowToolProvider.tenant_id == account.current_tenant.id,
WorkflowToolProvider.name == tool_name,
)
.count()
)
# The record should NOT exist because the transaction should be rolled back
# Currently, due to the bug, the record might exist (this test documents the bug)
# After the fix, this should always be 0
# For now, we document that the record may exist, demonstrating the bug
# assert tool_count == 0 # Expected after fix

View File

@ -0,0 +1,46 @@
from flask import Response
from controllers.common.file_response import enforce_download_for_html, is_html_content
class TestFileResponseHelpers:
def test_is_html_content_detects_mime_type(self):
mime_type = "text/html; charset=UTF-8"
result = is_html_content(mime_type, filename="file.txt", extension="txt")
assert result is True
def test_is_html_content_detects_extension(self):
result = is_html_content("text/plain", filename="report.html", extension=None)
assert result is True
def test_enforce_download_for_html_sets_headers(self):
response = Response("payload", mimetype="text/html")
updated = enforce_download_for_html(
response,
mime_type="text/html",
filename="unsafe.html",
extension="html",
)
assert updated is True
assert "attachment" in response.headers["Content-Disposition"]
assert response.headers["Content-Type"] == "application/octet-stream"
assert response.headers["X-Content-Type-Options"] == "nosniff"
def test_enforce_download_for_html_no_change_for_non_html(self):
response = Response("payload", mimetype="text/plain")
updated = enforce_download_for_html(
response,
mime_type="text/plain",
filename="notes.txt",
extension="txt",
)
assert updated is False
assert "Content-Disposition" not in response.headers
assert "X-Content-Type-Options" not in response.headers

View File

@ -0,0 +1,145 @@
"""Unit tests for load balancing credential validation APIs."""
from __future__ import annotations
import builtins
import importlib
import sys
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from flask import Flask
from flask.views import MethodView
from werkzeug.exceptions import Forbidden
from core.model_runtime.entities.model_entities import ModelType
from core.model_runtime.errors.validate import CredentialsValidateFailedError
if not hasattr(builtins, "MethodView"):
builtins.MethodView = MethodView # type: ignore[attr-defined]
from models.account import TenantAccountRole
@pytest.fixture
def app() -> Flask:
app = Flask(__name__)
app.config["TESTING"] = True
return app
@pytest.fixture
def load_balancing_module(monkeypatch: pytest.MonkeyPatch):
"""Reload controller module with lightweight decorators for testing."""
from controllers.console import console_ns, wraps
from libs import login
def _noop(func):
return func
monkeypatch.setattr(login, "login_required", _noop)
monkeypatch.setattr(wraps, "setup_required", _noop)
monkeypatch.setattr(wraps, "account_initialization_required", _noop)
def _noop_route(*args, **kwargs): # type: ignore[override]
def _decorator(cls):
return cls
return _decorator
monkeypatch.setattr(console_ns, "route", _noop_route)
module_name = "controllers.console.workspace.load_balancing_config"
sys.modules.pop(module_name, None)
module = importlib.import_module(module_name)
return module
def _mock_user(role: TenantAccountRole) -> SimpleNamespace:
return SimpleNamespace(current_role=role)
def _prepare_context(module, monkeypatch: pytest.MonkeyPatch, role=TenantAccountRole.OWNER):
user = _mock_user(role)
monkeypatch.setattr(module, "current_account_with_tenant", lambda: (user, "tenant-123"))
mock_service = MagicMock()
monkeypatch.setattr(module, "ModelLoadBalancingService", lambda: mock_service)
return mock_service
def _request_payload():
return {"model": "gpt-4o", "model_type": ModelType.LLM, "credentials": {"api_key": "sk-***"}}
def test_validate_credentials_success(app: Flask, load_balancing_module, monkeypatch: pytest.MonkeyPatch):
service = _prepare_context(load_balancing_module, monkeypatch)
with app.test_request_context(
"/workspaces/current/model-providers/openai/models/load-balancing-configs/credentials-validate",
method="POST",
json=_request_payload(),
):
response = load_balancing_module.LoadBalancingCredentialsValidateApi().post(provider="openai")
assert response == {"result": "success"}
service.validate_load_balancing_credentials.assert_called_once_with(
tenant_id="tenant-123",
provider="openai",
model="gpt-4o",
model_type=ModelType.LLM,
credentials={"api_key": "sk-***"},
)
def test_validate_credentials_returns_error_message(app: Flask, load_balancing_module, monkeypatch: pytest.MonkeyPatch):
service = _prepare_context(load_balancing_module, monkeypatch)
service.validate_load_balancing_credentials.side_effect = CredentialsValidateFailedError("invalid credentials")
with app.test_request_context(
"/workspaces/current/model-providers/openai/models/load-balancing-configs/credentials-validate",
method="POST",
json=_request_payload(),
):
response = load_balancing_module.LoadBalancingCredentialsValidateApi().post(provider="openai")
assert response == {"result": "error", "error": "invalid credentials"}
def test_validate_credentials_requires_privileged_role(
app: Flask, load_balancing_module, monkeypatch: pytest.MonkeyPatch
):
_prepare_context(load_balancing_module, monkeypatch, role=TenantAccountRole.NORMAL)
with app.test_request_context(
"/workspaces/current/model-providers/openai/models/load-balancing-configs/credentials-validate",
method="POST",
json=_request_payload(),
):
api = load_balancing_module.LoadBalancingCredentialsValidateApi()
with pytest.raises(Forbidden):
api.post(provider="openai")
def test_validate_credentials_with_config_id(app: Flask, load_balancing_module, monkeypatch: pytest.MonkeyPatch):
service = _prepare_context(load_balancing_module, monkeypatch)
with app.test_request_context(
"/workspaces/current/model-providers/openai/models/load-balancing-configs/cfg-1/credentials-validate",
method="POST",
json=_request_payload(),
):
response = load_balancing_module.LoadBalancingConfigCredentialsValidateApi().post(
provider="openai", config_id="cfg-1"
)
assert response == {"result": "success"}
service.validate_load_balancing_credentials.assert_called_once_with(
tenant_id="tenant-123",
provider="openai",
model="gpt-4o",
model_type=ModelType.LLM,
credentials={"api_key": "sk-***"},
config_id="cfg-1",
)

View File

@ -0,0 +1,103 @@
import json
from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from flask_restx import Api
from controllers.console.workspace.tool_providers import ToolProviderMCPApi
from core.db.session_factory import configure_session_factory
from extensions.ext_database import db
from services.tools.mcp_tools_manage_service import ReconnectResult
# Backward-compat fixtures referenced by @pytest.mark.usefixtures in this file.
# They are intentionally no-ops because the test already patches the required
# behaviors explicitly via @patch and context managers below.
@pytest.fixture
def _mock_cache():
return
@pytest.fixture
def _mock_user_tenant():
return
@pytest.fixture
def client():
app = Flask(__name__)
app.config["TESTING"] = True
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
api = Api(app)
api.add_resource(ToolProviderMCPApi, "/console/api/workspaces/current/tool-provider/mcp")
db.init_app(app)
# Configure session factory used by controller code
with app.app_context():
configure_session_factory(db.engine)
return app.test_client()
@patch(
"controllers.console.workspace.tool_providers.current_account_with_tenant", return_value=(MagicMock(id="u1"), "t1")
)
@patch("controllers.console.workspace.tool_providers.ToolProviderListCache.invalidate_cache", return_value=None)
@patch("controllers.console.workspace.tool_providers.Session")
@patch("controllers.console.workspace.tool_providers.MCPToolManageService._reconnect_with_url")
@pytest.mark.usefixtures("_mock_cache", "_mock_user_tenant")
def test_create_mcp_provider_populates_tools(
mock_reconnect, mock_session, mock_invalidate_cache, mock_current_account_with_tenant, client
):
# Arrange: reconnect returns tools immediately
mock_reconnect.return_value = ReconnectResult(
authed=True,
tools=json.dumps(
[{"name": "ping", "description": "ok", "inputSchema": {"type": "object"}, "outputSchema": {}}]
),
encrypted_credentials="{}",
)
# Fake service.create_provider -> returns object with id for reload
svc = MagicMock()
create_result = MagicMock()
create_result.id = "provider-1"
svc.create_provider.return_value = create_result
svc.get_provider.return_value = MagicMock(id="provider-1", tenant_id="t1") # used by reload path
mock_session.return_value.__enter__.return_value = MagicMock()
# Patch MCPToolManageService constructed inside controller
with patch("controllers.console.workspace.tool_providers.MCPToolManageService", return_value=svc):
payload = {
"server_url": "http://example.com/mcp",
"name": "demo",
"icon": "😀",
"icon_type": "emoji",
"icon_background": "#000",
"server_identifier": "demo-sid",
"configuration": {"timeout": 5, "sse_read_timeout": 30},
"headers": {},
"authentication": {},
}
# Act
with (
patch("controllers.console.wraps.dify_config.EDITION", "CLOUD"), # bypass setup_required DB check
patch("controllers.console.wraps.current_account_with_tenant", return_value=(MagicMock(id="u1"), "t1")),
patch("libs.login.check_csrf_token", return_value=None), # bypass CSRF in login_required
patch("libs.login._get_user", return_value=MagicMock(id="u1", is_authenticated=True)), # login
patch(
"services.tools.tools_transform_service.ToolTransformService.mcp_provider_to_user_provider",
return_value={"id": "provider-1", "tools": [{"name": "ping"}]},
),
):
resp = client.post(
"/console/api/workspaces/current/tool-provider/mcp",
data=json.dumps(payload),
content_type="application/json",
)
# Assert
assert resp.status_code == 200
body = resp.get_json()
assert body.get("id") == "provider-1"
# 若 transform 后包含 tools 字段,确保非空
assert isinstance(body.get("tools"), list)
assert body["tools"]

View File

@ -41,6 +41,7 @@ class TestFilePreviewApi:
upload_file = Mock(spec=UploadFile)
upload_file.id = str(uuid.uuid4())
upload_file.name = "test_file.jpg"
upload_file.extension = "jpg"
upload_file.mime_type = "image/jpeg"
upload_file.size = 1024
upload_file.key = "storage/key/test_file.jpg"
@ -210,6 +211,19 @@ class TestFilePreviewApi:
assert mock_upload_file.name in response.headers["Content-Disposition"]
assert response.headers["Content-Type"] == "application/octet-stream"
def test_build_file_response_html_forces_attachment(self, file_preview_api, mock_upload_file):
"""Test HTML files are forced to download"""
mock_generator = Mock()
mock_upload_file.mime_type = "text/html"
mock_upload_file.name = "unsafe.html"
mock_upload_file.extension = "html"
response = file_preview_api._build_file_response(mock_generator, mock_upload_file, False)
assert "attachment" in response.headers["Content-Disposition"]
assert response.headers["Content-Type"] == "application/octet-stream"
assert response.headers["X-Content-Type-Options"] == "nosniff"
def test_build_file_response_audio_video(self, file_preview_api, mock_upload_file):
"""Test file response building for audio/video files"""
mock_generator = Mock()

View File

@ -96,9 +96,6 @@ class TestToolProviderListCache:
ToolProviderListCache.invalidate_cache(tenant_id)
mock_redis_client.scan_iter.assert_called_once_with(f"tool_providers:tenant_id:{tenant_id}:*")
mock_redis_client.delete.assert_called_once_with(*mock_keys)
def test_invalidate_cache_no_keys(self, mock_redis_client):
"""Test invalidate cache - no cache keys for tenant"""
tenant_id = "tenant_123"

View File

@ -0,0 +1,472 @@
"""
Unit tests for SegmentService.get_segments method.
Tests the retrieval of document segments with pagination and filtering:
- Basic pagination (page, limit)
- Status filtering
- Keyword search
- Ordering by position and id (to avoid duplicate data)
"""
from unittest.mock import Mock, create_autospec, patch
import pytest
from models.dataset import DocumentSegment
class SegmentServiceTestDataFactory:
"""
Factory class for creating test data and mock objects for segment tests.
"""
@staticmethod
def create_segment_mock(
segment_id: str = "segment-123",
document_id: str = "doc-123",
tenant_id: str = "tenant-123",
dataset_id: str = "dataset-123",
position: int = 1,
content: str = "Test content",
status: str = "completed",
**kwargs,
) -> Mock:
"""
Create a mock document segment.
Args:
segment_id: Unique identifier for the segment
document_id: Parent document ID
tenant_id: Tenant ID the segment belongs to
dataset_id: Parent dataset ID
position: Position within the document
content: Segment text content
status: Indexing status
**kwargs: Additional attributes
Returns:
Mock: DocumentSegment mock object
"""
segment = create_autospec(DocumentSegment, instance=True)
segment.id = segment_id
segment.document_id = document_id
segment.tenant_id = tenant_id
segment.dataset_id = dataset_id
segment.position = position
segment.content = content
segment.status = status
for key, value in kwargs.items():
setattr(segment, key, value)
return segment
class TestSegmentServiceGetSegments:
"""
Comprehensive unit tests for SegmentService.get_segments method.
Tests cover:
- Basic pagination functionality
- Status list filtering
- Keyword search filtering
- Ordering (position + id for uniqueness)
- Empty results
- Combined filters
"""
@pytest.fixture
def mock_segment_service_dependencies(self):
"""
Common mock setup for segment service dependencies.
Patches:
- db: Database operations and pagination
- select: SQLAlchemy query builder
"""
with (
patch("services.dataset_service.db") as mock_db,
patch("services.dataset_service.select") as mock_select,
):
yield {
"db": mock_db,
"select": mock_select,
}
def test_get_segments_basic_pagination(self, mock_segment_service_dependencies):
"""
Test basic pagination functionality.
Verifies:
- Query is built with document_id and tenant_id filters
- Pagination uses correct page and limit parameters
- Returns segments and total count
"""
# Arrange
document_id = "doc-123"
tenant_id = "tenant-123"
page = 1
limit = 20
# Create mock segments
segment1 = SegmentServiceTestDataFactory.create_segment_mock(
segment_id="seg-1", position=1, content="First segment"
)
segment2 = SegmentServiceTestDataFactory.create_segment_mock(
segment_id="seg-2", position=2, content="Second segment"
)
# Mock pagination result
mock_paginated = Mock()
mock_paginated.items = [segment1, segment2]
mock_paginated.total = 2
mock_segment_service_dependencies["db"].paginate.return_value = mock_paginated
# Mock select builder
mock_query = Mock()
mock_segment_service_dependencies["select"].return_value = mock_query
mock_query.where.return_value = mock_query
mock_query.order_by.return_value = mock_query
# Act
from services.dataset_service import SegmentService
items, total = SegmentService.get_segments(document_id=document_id, tenant_id=tenant_id, page=page, limit=limit)
# Assert
assert len(items) == 2
assert total == 2
assert items[0].id == "seg-1"
assert items[1].id == "seg-2"
mock_segment_service_dependencies["db"].paginate.assert_called_once()
call_kwargs = mock_segment_service_dependencies["db"].paginate.call_args[1]
assert call_kwargs["page"] == page
assert call_kwargs["per_page"] == limit
assert call_kwargs["max_per_page"] == 100
assert call_kwargs["error_out"] is False
def test_get_segments_with_status_filter(self, mock_segment_service_dependencies):
"""
Test filtering by status list.
Verifies:
- Status list filter is applied to query
- Only segments with matching status are returned
"""
# Arrange
document_id = "doc-123"
tenant_id = "tenant-123"
status_list = ["completed", "indexing"]
segment1 = SegmentServiceTestDataFactory.create_segment_mock(segment_id="seg-1", status="completed")
segment2 = SegmentServiceTestDataFactory.create_segment_mock(segment_id="seg-2", status="indexing")
mock_paginated = Mock()
mock_paginated.items = [segment1, segment2]
mock_paginated.total = 2
mock_segment_service_dependencies["db"].paginate.return_value = mock_paginated
mock_query = Mock()
mock_segment_service_dependencies["select"].return_value = mock_query
mock_query.where.return_value = mock_query
mock_query.order_by.return_value = mock_query
# Act
from services.dataset_service import SegmentService
items, total = SegmentService.get_segments(
document_id=document_id, tenant_id=tenant_id, status_list=status_list
)
# Assert
assert len(items) == 2
assert total == 2
# Verify where was called multiple times (base filters + status filter)
assert mock_query.where.call_count >= 2
def test_get_segments_with_empty_status_list(self, mock_segment_service_dependencies):
"""
Test with empty status list.
Verifies:
- Empty status list is handled correctly
- No status filter is applied to avoid WHERE false condition
"""
# Arrange
document_id = "doc-123"
tenant_id = "tenant-123"
status_list = []
segment = SegmentServiceTestDataFactory.create_segment_mock(segment_id="seg-1")
mock_paginated = Mock()
mock_paginated.items = [segment]
mock_paginated.total = 1
mock_segment_service_dependencies["db"].paginate.return_value = mock_paginated
mock_query = Mock()
mock_segment_service_dependencies["select"].return_value = mock_query
mock_query.where.return_value = mock_query
mock_query.order_by.return_value = mock_query
# Act
from services.dataset_service import SegmentService
items, total = SegmentService.get_segments(
document_id=document_id, tenant_id=tenant_id, status_list=status_list
)
# Assert
assert len(items) == 1
assert total == 1
# Should only be called once (base filters, no status filter)
assert mock_query.where.call_count == 1
def test_get_segments_with_keyword_search(self, mock_segment_service_dependencies):
"""
Test keyword search functionality.
Verifies:
- Keyword filter uses ilike for case-insensitive search
- Search pattern includes wildcards (%keyword%)
"""
# Arrange
document_id = "doc-123"
tenant_id = "tenant-123"
keyword = "search term"
segment = SegmentServiceTestDataFactory.create_segment_mock(
segment_id="seg-1", content="This contains search term"
)
mock_paginated = Mock()
mock_paginated.items = [segment]
mock_paginated.total = 1
mock_segment_service_dependencies["db"].paginate.return_value = mock_paginated
mock_query = Mock()
mock_segment_service_dependencies["select"].return_value = mock_query
mock_query.where.return_value = mock_query
mock_query.order_by.return_value = mock_query
# Act
from services.dataset_service import SegmentService
items, total = SegmentService.get_segments(document_id=document_id, tenant_id=tenant_id, keyword=keyword)
# Assert
assert len(items) == 1
assert total == 1
# Verify where was called for base filters + keyword filter
assert mock_query.where.call_count == 2
def test_get_segments_ordering_by_position_and_id(self, mock_segment_service_dependencies):
"""
Test ordering by position and id.
Verifies:
- Results are ordered by position ASC
- Results are secondarily ordered by id ASC to ensure uniqueness
- This prevents duplicate data across pages when positions are not unique
"""
# Arrange
document_id = "doc-123"
tenant_id = "tenant-123"
# Create segments with same position but different ids
segment1 = SegmentServiceTestDataFactory.create_segment_mock(
segment_id="seg-1", position=1, content="Content 1"
)
segment2 = SegmentServiceTestDataFactory.create_segment_mock(
segment_id="seg-2", position=1, content="Content 2"
)
segment3 = SegmentServiceTestDataFactory.create_segment_mock(
segment_id="seg-3", position=2, content="Content 3"
)
mock_paginated = Mock()
mock_paginated.items = [segment1, segment2, segment3]
mock_paginated.total = 3
mock_segment_service_dependencies["db"].paginate.return_value = mock_paginated
mock_query = Mock()
mock_segment_service_dependencies["select"].return_value = mock_query
mock_query.where.return_value = mock_query
mock_query.order_by.return_value = mock_query
# Act
from services.dataset_service import SegmentService
items, total = SegmentService.get_segments(document_id=document_id, tenant_id=tenant_id)
# Assert
assert len(items) == 3
assert total == 3
mock_query.order_by.assert_called_once()
def test_get_segments_empty_results(self, mock_segment_service_dependencies):
"""
Test when no segments match the criteria.
Verifies:
- Empty list is returned for items
- Total count is 0
"""
# Arrange
document_id = "non-existent-doc"
tenant_id = "tenant-123"
mock_paginated = Mock()
mock_paginated.items = []
mock_paginated.total = 0
mock_segment_service_dependencies["db"].paginate.return_value = mock_paginated
mock_query = Mock()
mock_segment_service_dependencies["select"].return_value = mock_query
mock_query.where.return_value = mock_query
mock_query.order_by.return_value = mock_query
# Act
from services.dataset_service import SegmentService
items, total = SegmentService.get_segments(document_id=document_id, tenant_id=tenant_id)
# Assert
assert items == []
assert total == 0
def test_get_segments_combined_filters(self, mock_segment_service_dependencies):
"""
Test with multiple filters combined.
Verifies:
- All filters work together correctly
- Status list and keyword search both applied
"""
# Arrange
document_id = "doc-123"
tenant_id = "tenant-123"
status_list = ["completed"]
keyword = "important"
page = 2
limit = 10
segment = SegmentServiceTestDataFactory.create_segment_mock(
segment_id="seg-1",
status="completed",
content="This is important information",
)
mock_paginated = Mock()
mock_paginated.items = [segment]
mock_paginated.total = 1
mock_segment_service_dependencies["db"].paginate.return_value = mock_paginated
mock_query = Mock()
mock_segment_service_dependencies["select"].return_value = mock_query
mock_query.where.return_value = mock_query
mock_query.order_by.return_value = mock_query
# Act
from services.dataset_service import SegmentService
items, total = SegmentService.get_segments(
document_id=document_id,
tenant_id=tenant_id,
status_list=status_list,
keyword=keyword,
page=page,
limit=limit,
)
# Assert
assert len(items) == 1
assert total == 1
# Verify filters: base + status + keyword
assert mock_query.where.call_count == 3
# Verify pagination parameters
call_kwargs = mock_segment_service_dependencies["db"].paginate.call_args[1]
assert call_kwargs["page"] == page
assert call_kwargs["per_page"] == limit
def test_get_segments_with_none_status_list(self, mock_segment_service_dependencies):
"""
Test with None status list.
Verifies:
- None status list is handled correctly
- No status filter is applied
"""
# Arrange
document_id = "doc-123"
tenant_id = "tenant-123"
segment = SegmentServiceTestDataFactory.create_segment_mock(segment_id="seg-1")
mock_paginated = Mock()
mock_paginated.items = [segment]
mock_paginated.total = 1
mock_segment_service_dependencies["db"].paginate.return_value = mock_paginated
mock_query = Mock()
mock_segment_service_dependencies["select"].return_value = mock_query
mock_query.where.return_value = mock_query
mock_query.order_by.return_value = mock_query
# Act
from services.dataset_service import SegmentService
items, total = SegmentService.get_segments(
document_id=document_id,
tenant_id=tenant_id,
status_list=None,
)
# Assert
assert len(items) == 1
assert total == 1
# Should only be called once (base filters only, no status filter)
assert mock_query.where.call_count == 1
def test_get_segments_pagination_max_per_page_limit(self, mock_segment_service_dependencies):
"""
Test that max_per_page is correctly set to 100.
Verifies:
- max_per_page parameter is set to 100
- This prevents excessive page sizes
"""
# Arrange
document_id = "doc-123"
tenant_id = "tenant-123"
limit = 200 # Request more than max_per_page
mock_paginated = Mock()
mock_paginated.items = []
mock_paginated.total = 0
mock_segment_service_dependencies["db"].paginate.return_value = mock_paginated
mock_query = Mock()
mock_segment_service_dependencies["select"].return_value = mock_query
mock_query.where.return_value = mock_query
mock_query.order_by.return_value = mock_query
# Act
from services.dataset_service import SegmentService
SegmentService.get_segments(
document_id=document_id,
tenant_id=tenant_id,
limit=limit,
)
# Assert
call_kwargs = mock_segment_service_dependencies["db"].paginate.call_args[1]
assert call_kwargs["max_per_page"] == 100

View File

@ -1368,7 +1368,7 @@ wheels = [
[[package]]
name = "dify-api"
version = "1.11.1"
version = "1.11.2"
source = { virtual = "." }
dependencies = [
{ name = "aliyun-log-python-sdk" },

View File

@ -399,6 +399,7 @@ CONSOLE_CORS_ALLOW_ORIGINS=*
COOKIE_DOMAIN=
# When the frontend and backend run on different subdomains, set NEXT_PUBLIC_COOKIE_DOMAIN=1.
NEXT_PUBLIC_COOKIE_DOMAIN=
NEXT_PUBLIC_BATCH_CONCURRENCY=5
# ------------------------------
# File Storage Configuration

View File

@ -21,7 +21,7 @@ services:
# API service
api:
image: langgenius/dify-api:1.11.1
image: langgenius/dify-api:1.11.2
restart: always
environment:
# Use the shared environment variables.
@ -63,7 +63,7 @@ services:
# worker service
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
worker:
image: langgenius/dify-api:1.11.1
image: langgenius/dify-api:1.11.2
restart: always
environment:
# Use the shared environment variables.
@ -102,7 +102,7 @@ services:
# worker_beat service
# Celery beat for scheduling periodic tasks.
worker_beat:
image: langgenius/dify-api:1.11.1
image: langgenius/dify-api:1.11.2
restart: always
environment:
# Use the shared environment variables.
@ -132,7 +132,7 @@ services:
# Frontend web application.
web:
image: langgenius/dify-web:1.11.1
image: langgenius/dify-web:1.11.2
restart: always
environment:
CONSOLE_API_URL: ${CONSOLE_API_URL:-}

View File

@ -108,6 +108,7 @@ x-shared-env: &shared-api-worker-env
CONSOLE_CORS_ALLOW_ORIGINS: ${CONSOLE_CORS_ALLOW_ORIGINS:-*}
COOKIE_DOMAIN: ${COOKIE_DOMAIN:-}
NEXT_PUBLIC_COOKIE_DOMAIN: ${NEXT_PUBLIC_COOKIE_DOMAIN:-}
NEXT_PUBLIC_BATCH_CONCURRENCY: ${NEXT_PUBLIC_BATCH_CONCURRENCY:-5}
STORAGE_TYPE: ${STORAGE_TYPE:-opendal}
OPENDAL_SCHEME: ${OPENDAL_SCHEME:-fs}
OPENDAL_FS_ROOT: ${OPENDAL_FS_ROOT:-storage}
@ -692,7 +693,7 @@ services:
# API service
api:
image: langgenius/dify-api:1.11.1
image: langgenius/dify-api:1.11.2
restart: always
environment:
# Use the shared environment variables.
@ -734,7 +735,7 @@ services:
# worker service
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
worker:
image: langgenius/dify-api:1.11.1
image: langgenius/dify-api:1.11.2
restart: always
environment:
# Use the shared environment variables.
@ -773,7 +774,7 @@ services:
# worker_beat service
# Celery beat for scheduling periodic tasks.
worker_beat:
image: langgenius/dify-api:1.11.1
image: langgenius/dify-api:1.11.2
restart: always
environment:
# Use the shared environment variables.
@ -803,7 +804,7 @@ services:
# Frontend web application.
web:
image: langgenius/dify-web:1.11.1
image: langgenius/dify-web:1.11.2
restart: always
environment:
CONSOLE_API_URL: ${CONSOLE_API_URL:-}

View File

@ -73,3 +73,6 @@ NEXT_PUBLIC_MAX_TREE_DEPTH=50
# The API key of amplitude
NEXT_PUBLIC_AMPLITUDE_API_KEY=
# number of concurrency
NEXT_PUBLIC_BATCH_CONCURRENCY=5

View File

@ -176,7 +176,7 @@ const DatasetConfig: FC = () => {
}))
}, [setDatasetConfigs, datasetConfigsRef])
const handleAddCondition = useCallback<HandleAddCondition>(({ name, type }) => {
const handleAddCondition = useCallback<HandleAddCondition>(({ id, name, type }) => {
let operator: ComparisonOperator = ComparisonOperator.is
if (type === MetadataFilteringVariableType.number)
@ -184,6 +184,7 @@ const DatasetConfig: FC = () => {
const newCondition = {
id: uuid4(),
metadata_id: id, // Save metadata.id for reliable reference
name,
comparison_operator: operator,
}

View File

@ -679,7 +679,7 @@ const Configuration: FC = () => {
const toolInCollectionList = collectionList.find(c => tool.provider_id === c.id)
return {
...tool,
isDeleted: res.deleted_tools?.some((deletedTool: any) => deletedTool.id === tool.id && deletedTool.tool_name === tool.tool_name) ?? false,
isDeleted: res.deleted_tools?.some((deletedTool: any) => deletedTool.provider_id === tool.provider_id && deletedTool.tool_name === tool.tool_name) ?? false,
notAuthor: toolInCollectionList?.is_team_authorization === false,
...(tool.provider_type === 'builtin'
? {

View File

@ -0,0 +1,136 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { AppModeEnum } from '@/types/app'
import Apps from './index'
const mockUseExploreAppList = vi.fn()
vi.mock('ahooks', () => ({
useDebounceFn: (fn: () => void) => ({
run: () => setTimeout(fn, 0),
cancel: vi.fn(),
flush: () => fn(),
}),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({ isCurrentWorkspaceEditor: true }),
}))
vi.mock('use-context-selector', async () => {
const actual = await vi.importActual<typeof import('use-context-selector')>('use-context-selector')
return {
...actual,
useContext: () => ({ hasEditPermission: true }),
}
})
vi.mock('@/hooks/use-tab-searchparams', () => ({
useTabSearchParams: () => ['Recommended', vi.fn()],
}))
vi.mock('@/service/use-explore', () => ({
useExploreAppList: () => mockUseExploreAppList(),
}))
vi.mock('@/app/components/app/type-selector', () => ({
__esModule: true,
default: ({ value, onChange }: { value: AppModeEnum[], onChange: (value: AppModeEnum[]) => void }) => (
<button data-testid="type-selector" onClick={() => onChange([...value, 'chat' as AppModeEnum])}>{value.join(',')}</button>
),
}))
vi.mock('../app-card', () => ({
__esModule: true,
default: ({ app, onCreate }: { app: any, onCreate: () => void }) => (
<div
data-testid="app-card"
data-name={app.app.name}
onClick={onCreate}
>
{app.app.name}
</div>
),
}))
vi.mock('@/app/components/explore/create-app-modal', () => ({
__esModule: true,
default: () => <div data-testid="create-from-template-modal" />,
}))
vi.mock('@/app/components/base/toast', () => ({
default: { notify: vi.fn() },
}))
vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: vi.fn(),
}))
vi.mock('@/service/apps', () => ({
importDSL: vi.fn().mockResolvedValue({ app_id: '1' }),
}))
vi.mock('@/service/explore', () => ({
fetchAppDetail: vi.fn().mockResolvedValue({
export_data: 'dsl',
mode: 'chat',
}),
}))
vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({
usePluginDependencies: () => ({
handleCheckPluginDependencies: vi.fn(),
}),
}))
vi.mock('@/utils/app-redirection', () => ({
getRedirection: vi.fn(),
}))
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: vi.fn() }),
}))
const createAppEntry = (name: string, category: string) => ({
app_id: name,
category,
app: {
id: name,
name,
icon_type: 'emoji',
icon: '🙂',
icon_background: '#000',
icon_url: null,
description: 'desc',
mode: AppModeEnum.CHAT,
},
})
describe('Apps', () => {
const defaultData = {
allList: [
createAppEntry('Alpha', 'Cat A'),
createAppEntry('Bravo', 'Cat B'),
],
categories: ['Cat A', 'Cat B'],
}
beforeEach(() => {
vi.clearAllMocks()
mockUseExploreAppList.mockReturnValue({
data: defaultData,
isLoading: false,
})
})
it('renders template cards when data is available', () => {
render(<Apps />)
expect(screen.getAllByTestId('app-card')).toHaveLength(2)
expect(screen.getByText('Alpha')).toBeInTheDocument()
expect(screen.getByText('Bravo')).toBeInTheDocument()
})
it('opens create modal when a template card is clicked', () => {
render(<Apps />)
fireEvent.click(screen.getAllByTestId('app-card')[0])
expect(screen.getByTestId('create-from-template-modal')).toBeInTheDocument()
})
it('shows no template message when list is empty', () => {
mockUseExploreAppList.mockReturnValueOnce({
data: { allList: [], categories: [] },
isLoading: false,
})
render(<Apps />)
expect(screen.getByText('app.newApp.noTemplateFound')).toBeInTheDocument()
expect(screen.getByText('app.newApp.noTemplateFoundTip')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,38 @@
import { fireEvent, render, screen } from '@testing-library/react'
import Sidebar, { AppCategories } from './sidebar'
vi.mock('@remixicon/react', () => ({
RiStickyNoteAddLine: () => <span>sticky</span>,
RiThumbUpLine: () => <span>thumb</span>,
}))
describe('Sidebar', () => {
it('renders recommended and custom categories', () => {
render(<Sidebar current={AppCategories.RECOMMENDED} categories={['Cat A', 'Cat B']} />)
expect(screen.getByText('app.newAppFromTemplate.sidebar.Recommended')).toBeInTheDocument()
expect(screen.getByText('Cat A')).toBeInTheDocument()
expect(screen.getByText('Cat B')).toBeInTheDocument()
})
it('notifies callbacks when items are clicked', () => {
const onClick = vi.fn()
const onCreate = vi.fn()
render(
<Sidebar
current="Cat A"
categories={['Cat A']}
onClick={onClick}
onCreateFromBlank={onCreate}
/>,
)
fireEvent.click(screen.getByText('app.newAppFromTemplate.sidebar.Recommended'))
expect(onClick).toHaveBeenCalledWith(AppCategories.RECOMMENDED)
fireEvent.click(screen.getByText('Cat A'))
expect(onClick).toHaveBeenCalledWith('Cat A')
fireEvent.click(screen.getByText('app.newApp.startFromBlank'))
expect(onCreate).toHaveBeenCalled()
})
})

View File

@ -0,0 +1,217 @@
import type { ReactNode } from 'react'
import type { ModalContextState } from '@/context/modal-context'
import type { ProviderContextState } from '@/context/provider-context'
import type { AppDetailResponse } from '@/models/app'
import type { AppSSO } from '@/types/app'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { Plan } from '@/app/components/billing/type'
import { baseProviderContextValue } from '@/context/provider-context'
import { AppModeEnum } from '@/types/app'
import SettingsModal from './index'
vi.mock('react-i18next', async () => {
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
return {
...actual,
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
if (options?.returnObjects)
return [`${key}-feature-1`, `${key}-feature-2`]
if (options)
return `${key}:${JSON.stringify(options)}`
return key
},
i18n: {
language: 'en',
changeLanguage: vi.fn(),
},
}),
Trans: ({ children }: { children?: ReactNode }) => <>{children}</>,
}
})
const mockNotify = vi.fn()
const mockOnClose = vi.fn()
const mockOnSave = vi.fn()
const mockSetShowPricingModal = vi.fn()
const mockSetShowAccountSettingModal = vi.fn()
const mockUseProviderContext = vi.fn<() => ProviderContextState>()
const buildModalContext = (): ModalContextState => ({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
setShowApiBasedExtensionModal: vi.fn(),
setShowModerationSettingModal: vi.fn(),
setShowExternalDataToolModal: vi.fn(),
setShowPricingModal: mockSetShowPricingModal,
setShowAnnotationFullModal: vi.fn(),
setShowModelModal: vi.fn(),
setShowExternalKnowledgeAPIModal: vi.fn(),
setShowModelLoadBalancingModal: vi.fn(),
setShowOpeningModal: vi.fn(),
setShowUpdatePluginModal: vi.fn(),
setShowEducationExpireNoticeModal: vi.fn(),
setShowTriggerEventsLimitModal: vi.fn(),
})
vi.mock('@/context/modal-context', () => ({
useModalContext: () => buildModalContext(),
}))
vi.mock('@/app/components/base/toast', async () => {
const actual = await vi.importActual<typeof import('@/app/components/base/toast')>('@/app/components/base/toast')
return {
...actual,
useToastContext: () => ({
notify: mockNotify,
close: vi.fn(),
}),
}
})
vi.mock('@/context/i18n', async () => {
const actual = await vi.importActual<typeof import('@/context/i18n')>('@/context/i18n')
return {
...actual,
useDocLink: () => (path?: string) => `https://docs.example.com${path ?? ''}`,
}
})
vi.mock('@/context/provider-context', async () => {
const actual = await vi.importActual<typeof import('@/context/provider-context')>('@/context/provider-context')
return {
...actual,
useProviderContext: () => mockUseProviderContext(),
}
})
const mockAppInfo = {
site: {
title: 'Test App',
icon_type: 'emoji',
icon: '😀',
icon_background: '#ABCDEF',
icon_url: 'https://example.com/icon.png',
description: 'A description',
chat_color_theme: '#123456',
chat_color_theme_inverted: true,
copyright: '© Dify',
privacy_policy: '',
custom_disclaimer: 'Disclaimer',
default_language: 'en-US',
show_workflow_steps: true,
use_icon_as_answer_icon: true,
},
mode: AppModeEnum.ADVANCED_CHAT,
enable_sso: false,
} as unknown as AppDetailResponse & Partial<AppSSO>
const renderSettingsModal = () => render(
<SettingsModal
isChat
isShow
appInfo={mockAppInfo}
onClose={mockOnClose}
onSave={mockOnSave}
/>,
)
describe('SettingsModal', () => {
beforeEach(() => {
mockNotify.mockClear()
mockOnClose.mockClear()
mockOnSave.mockClear()
mockSetShowPricingModal.mockClear()
mockSetShowAccountSettingModal.mockClear()
mockUseProviderContext.mockReturnValue({
...baseProviderContextValue,
enableBilling: true,
plan: {
...baseProviderContextValue.plan,
type: Plan.sandbox,
},
webappCopyrightEnabled: true,
})
})
it('should render the modal and expose the expanded settings section', async () => {
renderSettingsModal()
expect(screen.getByText('appOverview.overview.appInfo.settings.title')).toBeInTheDocument()
const showMoreEntry = screen.getByText('appOverview.overview.appInfo.settings.more.entry')
fireEvent.click(showMoreEntry)
await waitFor(() => {
expect(screen.getByPlaceholderText('appOverview.overview.appInfo.settings.more.copyRightPlaceholder')).toBeInTheDocument()
expect(screen.getByPlaceholderText('appOverview.overview.appInfo.settings.more.privacyPolicyPlaceholder')).toBeInTheDocument()
})
})
it('should notify the user when the name is empty', async () => {
renderSettingsModal()
const nameInput = screen.getByPlaceholderText('app.appNamePlaceholder')
fireEvent.change(nameInput, { target: { value: '' } })
fireEvent.click(screen.getByText('common.operation.save'))
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ message: 'app.newApp.nameNotEmpty' }))
})
expect(mockOnSave).not.toHaveBeenCalled()
})
it('should validate the theme color and show an error when the hex is invalid', async () => {
renderSettingsModal()
const colorInput = screen.getByPlaceholderText('E.g #A020F0')
fireEvent.change(colorInput, { target: { value: 'not-a-hex' } })
fireEvent.click(screen.getByText('common.operation.save'))
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
message: 'appOverview.overview.appInfo.settings.invalidHexMessage',
}))
})
expect(mockOnSave).not.toHaveBeenCalled()
})
it('should validate the privacy policy URL when advanced settings are open', async () => {
renderSettingsModal()
fireEvent.click(screen.getByText('appOverview.overview.appInfo.settings.more.entry'))
const privacyInput = screen.getByPlaceholderText('appOverview.overview.appInfo.settings.more.privacyPolicyPlaceholder')
// eslint-disable-next-line sonarjs/no-clear-text-protocols
fireEvent.change(privacyInput, { target: { value: 'ftp://invalid-url' } })
fireEvent.click(screen.getByText('common.operation.save'))
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
message: 'appOverview.overview.appInfo.settings.invalidPrivacyPolicy',
}))
})
expect(mockOnSave).not.toHaveBeenCalled()
})
it('should save valid settings and close the modal', async () => {
mockOnSave.mockResolvedValueOnce(undefined)
renderSettingsModal()
fireEvent.click(screen.getByText('common.operation.save'))
await waitFor(() => expect(mockOnSave).toHaveBeenCalled())
expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({
title: mockAppInfo.site.title,
description: mockAppInfo.site.description,
default_language: mockAppInfo.site.default_language,
chat_color_theme: mockAppInfo.site.chat_color_theme,
chat_color_theme_inverted: mockAppInfo.site.chat_color_theme_inverted,
prompt_public: false,
copyright: mockAppInfo.site.copyright,
privacy_policy: mockAppInfo.site.privacy_policy,
custom_disclaimer: mockAppInfo.site.custom_disclaimer,
icon_type: 'emoji',
icon: mockAppInfo.site.icon,
icon_background: mockAppInfo.site.icon_background,
show_workflow_steps: mockAppInfo.site.show_workflow_steps,
use_icon_as_answer_icon: mockAppInfo.site.use_icon_as_answer_icon,
enable_sso: mockAppInfo.enable_sso,
}))
expect(mockOnClose).toHaveBeenCalled()
})
})

View File

@ -0,0 +1,360 @@
import { fireEvent, render, screen } from '@testing-library/react'
import Badge, { BadgeState, BadgeVariants } from './index'
describe('Badge', () => {
describe('Rendering', () => {
it('should render as a div element with badge class', () => {
render(<Badge>Test Badge</Badge>)
const badge = screen.getByText('Test Badge')
expect(badge).toHaveClass('badge')
expect(badge.tagName).toBe('DIV')
})
it.each([
{ children: undefined, label: 'no children' },
{ children: '', label: 'empty string' },
])('should render correctly when provided $label', ({ children }) => {
const { container } = render(<Badge>{children}</Badge>)
expect(container.firstChild).toHaveClass('badge')
})
it('should render React Node children correctly', () => {
render(
<Badge data-testid="badge-with-icon">
<span data-testid="custom-icon">🔔</span>
</Badge>,
)
expect(screen.getByTestId('badge-with-icon')).toBeInTheDocument()
expect(screen.getByTestId('custom-icon')).toBeInTheDocument()
})
})
describe('size prop', () => {
it.each([
{ size: undefined, label: 'medium (default)' },
{ size: 's', label: 'small' },
{ size: 'm', label: 'medium' },
{ size: 'l', label: 'large' },
] as const)('should render with $label size', ({ size }) => {
render(<Badge size={size}>Test</Badge>)
const expectedSize = size || 'm'
expect(screen.getByText('Test')).toHaveClass('badge', `badge-${expectedSize}`)
})
})
describe('state prop', () => {
it.each([
{ state: BadgeState.Warning, label: 'warning', expectedClass: 'badge-warning' },
{ state: BadgeState.Accent, label: 'accent', expectedClass: 'badge-accent' },
])('should render with $label state', ({ state, expectedClass }) => {
render(<Badge state={state}>State Test</Badge>)
expect(screen.getByText('State Test')).toHaveClass(expectedClass)
})
it.each([
{ state: undefined, label: 'default (undefined)' },
{ state: BadgeState.Default, label: 'default (explicit)' },
])('should use default styles when state is $label', ({ state }) => {
render(<Badge state={state}>State Test</Badge>)
const badge = screen.getByText('State Test')
expect(badge).not.toHaveClass('badge-warning', 'badge-accent')
})
})
describe('iconOnly prop', () => {
it.each([
{ size: 's', iconOnly: false, label: 'small with text' },
{ size: 's', iconOnly: true, label: 'small icon-only' },
{ size: 'm', iconOnly: false, label: 'medium with text' },
{ size: 'm', iconOnly: true, label: 'medium icon-only' },
{ size: 'l', iconOnly: false, label: 'large with text' },
{ size: 'l', iconOnly: true, label: 'large icon-only' },
] as const)('should render correctly for $label', ({ size, iconOnly }) => {
const { container } = render(<Badge size={size} iconOnly={iconOnly}>🔔</Badge>)
const badge = screen.getByText('🔔')
// Verify badge renders with correct size
expect(badge).toHaveClass('badge', `badge-${size}`)
// Verify the badge is in the DOM and contains the content
expect(badge).toBeInTheDocument()
expect(container.firstChild).toBe(badge)
})
it('should apply icon-only padding when iconOnly is true', () => {
render(<Badge iconOnly>🔔</Badge>)
// When iconOnly is true, the badge should have uniform padding (all sides equal)
const badge = screen.getByText('🔔')
expect(badge).toHaveClass('p-1')
})
it('should apply asymmetric padding when iconOnly is false', () => {
render(<Badge iconOnly={false}>Badge</Badge>)
// When iconOnly is false, the badge should have different horizontal and vertical padding
const badge = screen.getByText('Badge')
expect(badge).toHaveClass('px-[5px]', 'py-[2px]')
})
})
describe('uppercase prop', () => {
it.each([
{ uppercase: undefined, label: 'default (undefined)', expected: 'system-2xs-medium' },
{ uppercase: false, label: 'explicitly false', expected: 'system-2xs-medium' },
{ uppercase: true, label: 'true', expected: 'system-2xs-medium-uppercase' },
])('should apply $expected class when uppercase is $label', ({ uppercase, expected }) => {
render(<Badge uppercase={uppercase}>Text</Badge>)
expect(screen.getByText('Text')).toHaveClass(expected)
})
})
describe('styleCss prop', () => {
it('should apply custom inline styles correctly', () => {
const customStyles = {
backgroundColor: 'rgb(0, 0, 255)',
color: 'rgb(255, 255, 255)',
padding: '10px',
}
render(<Badge styleCss={customStyles}>Styled Badge</Badge>)
expect(screen.getByText('Styled Badge')).toHaveStyle(customStyles)
})
it('should apply inline styles without overriding core classes', () => {
render(<Badge styleCss={{ backgroundColor: 'rgb(255, 0, 0)', margin: '5px' }}>Custom</Badge>)
const badge = screen.getByText('Custom')
expect(badge).toHaveStyle({ backgroundColor: 'rgb(255, 0, 0)', margin: '5px' })
expect(badge).toHaveClass('badge')
})
})
describe('className prop', () => {
it.each([
{
props: { className: 'custom-badge' },
expected: ['badge', 'custom-badge'],
label: 'single custom class',
},
{
props: { className: 'custom-class another-class', size: 'l' as const },
expected: ['badge', 'badge-l', 'custom-class', 'another-class'],
label: 'multiple classes with size variant',
},
])('should merge $label with default classes', ({ props, expected }) => {
render(<Badge {...props}>Test</Badge>)
expect(screen.getByText('Test')).toHaveClass(...expected)
})
})
describe('HTML attributes passthrough', () => {
it.each([
{ attr: 'data-testid', value: 'custom-badge-id', label: 'data attribute' },
{ attr: 'id', value: 'unique-badge', label: 'id attribute' },
{ attr: 'aria-label', value: 'Notification badge', label: 'aria-label' },
{ attr: 'title', value: 'Hover tooltip', label: 'title attribute' },
{ attr: 'role', value: 'status', label: 'ARIA role' },
])('should pass through $label correctly', ({ attr, value }) => {
render(<Badge {...{ [attr]: value }}>Test</Badge>)
expect(screen.getByText('Test')).toHaveAttribute(attr, value)
})
it('should support multiple HTML attributes simultaneously', () => {
render(
<Badge
data-testid="multi-attr-badge"
id="badge-123"
aria-label="Status indicator"
title="Current status"
>
Test
</Badge>,
)
const badge = screen.getByTestId('multi-attr-badge')
expect(badge).toHaveAttribute('id', 'badge-123')
expect(badge).toHaveAttribute('aria-label', 'Status indicator')
expect(badge).toHaveAttribute('title', 'Current status')
})
})
describe('Event handlers', () => {
it.each([
{ handler: 'onClick', trigger: fireEvent.click, label: 'click' },
{ handler: 'onMouseEnter', trigger: fireEvent.mouseEnter, label: 'mouse enter' },
{ handler: 'onMouseLeave', trigger: fireEvent.mouseLeave, label: 'mouse leave' },
])('should trigger $handler when $label occurs', ({ handler, trigger }) => {
const mockHandler = vi.fn()
render(<Badge {...{ [handler]: mockHandler }}>Badge</Badge>)
trigger(screen.getByText('Badge'))
expect(mockHandler).toHaveBeenCalledTimes(1)
})
it('should handle user interaction flow with multiple events', () => {
const handlers = {
onClick: vi.fn(),
onMouseEnter: vi.fn(),
onMouseLeave: vi.fn(),
}
render(<Badge {...handlers}>Interactive</Badge>)
const badge = screen.getByText('Interactive')
fireEvent.mouseEnter(badge)
fireEvent.click(badge)
fireEvent.mouseLeave(badge)
expect(handlers.onMouseEnter).toHaveBeenCalledTimes(1)
expect(handlers.onClick).toHaveBeenCalledTimes(1)
expect(handlers.onMouseLeave).toHaveBeenCalledTimes(1)
})
it('should pass event object to handler with correct properties', () => {
const handleClick = vi.fn()
render(<Badge onClick={handleClick}>Event Badge</Badge>)
fireEvent.click(screen.getByText('Event Badge'))
expect(handleClick).toHaveBeenCalledWith(expect.objectContaining({
type: 'click',
}))
})
})
describe('Combined props', () => {
it('should correctly apply all props when used together', () => {
render(
<Badge
size="l"
state={BadgeState.Warning}
uppercase
className="custom-badge"
styleCss={{ backgroundColor: 'rgb(0, 0, 255)' }}
data-testid="combined-badge"
>
Full Featured
</Badge>,
)
const badge = screen.getByTestId('combined-badge')
expect(badge).toHaveClass('badge', 'badge-l', 'badge-warning', 'system-2xs-medium-uppercase', 'custom-badge')
expect(badge).toHaveStyle({ backgroundColor: 'rgb(0, 0, 255)' })
expect(badge).toHaveTextContent('Full Featured')
})
it.each([
{
props: { size: 'l' as const, state: BadgeState.Accent },
expected: ['badge', 'badge-l', 'badge-accent'],
label: 'size and state variants',
},
{
props: { iconOnly: true, uppercase: true },
expected: ['badge', 'system-2xs-medium-uppercase'],
label: 'iconOnly and uppercase',
},
])('should combine $label correctly', ({ props, expected }) => {
render(<Badge {...props}>Test</Badge>)
expect(screen.getByText('Test')).toHaveClass(...expected)
})
it('should handle event handlers with combined props', () => {
const handleClick = vi.fn()
render(
<Badge size="s" state={BadgeState.Warning} onClick={handleClick} className="interactive">
Test
</Badge>,
)
const badge = screen.getByText('Test')
expect(badge).toHaveClass('badge', 'badge-s', 'badge-warning', 'interactive')
fireEvent.click(badge)
expect(handleClick).toHaveBeenCalledTimes(1)
})
})
describe('Edge cases', () => {
it.each([
{ children: 42, text: '42', label: 'numeric value' },
{ children: 0, text: '0', label: 'zero' },
])('should render $label correctly', ({ children, text }) => {
render(<Badge>{children}</Badge>)
expect(screen.getByText(text)).toBeInTheDocument()
})
it.each([
{ children: null, label: 'null' },
{ children: false, label: 'boolean false' },
])('should handle $label children without errors', ({ children }) => {
const { container } = render(<Badge>{children}</Badge>)
expect(container.firstChild).toHaveClass('badge')
})
it('should render complex nested content correctly', () => {
render(
<Badge>
<span data-testid="icon">🔔</span>
<span data-testid="count">5</span>
</Badge>,
)
expect(screen.getByTestId('icon')).toBeInTheDocument()
expect(screen.getByTestId('count')).toBeInTheDocument()
})
})
describe('Component metadata and exports', () => {
it('should have correct displayName for debugging', () => {
expect(Badge.displayName).toBe('Badge')
})
describe('BadgeState enum', () => {
it.each([
{ key: 'Warning', value: 'warning' },
{ key: 'Accent', value: 'accent' },
{ key: 'Default', value: '' },
])('should export $key state with value "$value"', ({ key, value }) => {
expect(BadgeState[key as keyof typeof BadgeState]).toBe(value)
})
})
describe('BadgeVariants utility', () => {
it('should be a function', () => {
expect(typeof BadgeVariants).toBe('function')
})
it('should generate base badge class with default medium size', () => {
const result = BadgeVariants({})
expect(result).toContain('badge')
expect(result).toContain('badge-m')
})
it.each([
{ size: 's' },
{ size: 'm' },
{ size: 'l' },
] as const)('should generate correct classes for size=$size', ({ size }) => {
const result = BadgeVariants({ size })
expect(result).toContain('badge')
expect(result).toContain(`badge-${size}`)
})
})
})
})

View File

@ -0,0 +1,84 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import Billing from './index'
let currentBillingUrl: string | null = 'https://billing'
let fetching = false
let isManager = true
let enableBilling = true
const refetchMock = vi.fn()
const openAsyncWindowMock = vi.fn()
vi.mock('@/service/use-billing', () => ({
useBillingUrl: () => ({
data: currentBillingUrl,
isFetching: fetching,
refetch: refetchMock,
}),
}))
vi.mock('@/hooks/use-async-window-open', () => ({
useAsyncWindowOpen: () => openAsyncWindowMock,
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceManager: isManager,
}),
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
enableBilling,
}),
}))
vi.mock('../plan', () => ({
__esModule: true,
default: ({ loc }: { loc: string }) => <div data-testid="plan-component" data-loc={loc} />,
}))
describe('Billing', () => {
beforeEach(() => {
vi.clearAllMocks()
currentBillingUrl = 'https://billing'
fetching = false
isManager = true
enableBilling = true
refetchMock.mockResolvedValue({ data: 'https://billing' })
})
it('hides the billing action when user is not manager or billing is disabled', () => {
isManager = false
render(<Billing />)
expect(screen.queryByRole('button', { name: /billing\.viewBillingTitle/ })).not.toBeInTheDocument()
vi.clearAllMocks()
isManager = true
enableBilling = false
render(<Billing />)
expect(screen.queryByRole('button', { name: /billing\.viewBillingTitle/ })).not.toBeInTheDocument()
})
it('opens the billing window with the immediate url when the button is clicked', async () => {
render(<Billing />)
const actionButton = screen.getByRole('button', { name: /billing\.viewBillingTitle/ })
fireEvent.click(actionButton)
await waitFor(() => expect(openAsyncWindowMock).toHaveBeenCalled())
const [, options] = openAsyncWindowMock.mock.calls[0]
expect(options).toMatchObject({
immediateUrl: currentBillingUrl,
features: 'noopener,noreferrer',
})
})
it('disables the button while billing url is fetching', () => {
fetching = true
render(<Billing />)
const actionButton = screen.getByRole('button', { name: /billing\.viewBillingTitle/ })
expect(actionButton).toBeDisabled()
})
})

View File

@ -0,0 +1,92 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { Plan } from '../type'
import HeaderBillingBtn from './index'
type HeaderGlobal = typeof globalThis & {
__mockProviderContext?: ReturnType<typeof vi.fn>
}
function getHeaderGlobal(): HeaderGlobal {
return globalThis as HeaderGlobal
}
const ensureProviderContextMock = () => {
const globals = getHeaderGlobal()
if (!globals.__mockProviderContext)
throw new Error('Provider context mock not set')
return globals.__mockProviderContext
}
vi.mock('@/context/provider-context', () => {
const mock = vi.fn()
const globals = getHeaderGlobal()
globals.__mockProviderContext = mock
return {
useProviderContext: () => mock(),
}
})
vi.mock('../upgrade-btn', () => ({
__esModule: true,
default: () => <button data-testid="upgrade-btn" type="button">Upgrade</button>,
}))
describe('HeaderBillingBtn', () => {
beforeEach(() => {
vi.clearAllMocks()
ensureProviderContextMock().mockReturnValue({
plan: {
type: Plan.professional,
},
enableBilling: true,
isFetchedPlan: true,
})
})
it('renders nothing when billing is disabled or plan is not fetched', () => {
ensureProviderContextMock().mockReturnValueOnce({
plan: {
type: Plan.professional,
},
enableBilling: false,
isFetchedPlan: true,
})
const { container } = render(<HeaderBillingBtn />)
expect(container.firstChild).toBeNull()
})
it('renders upgrade button for sandbox plan', () => {
ensureProviderContextMock().mockReturnValueOnce({
plan: {
type: Plan.sandbox,
},
enableBilling: true,
isFetchedPlan: true,
})
render(<HeaderBillingBtn />)
expect(screen.getByTestId('upgrade-btn')).toBeInTheDocument()
})
it('renders plan badge and forwards clicks when not display-only', () => {
const onClick = vi.fn()
const { rerender } = render(<HeaderBillingBtn onClick={onClick} />)
const badge = screen.getByText('pro').closest('div')
expect(badge).toHaveClass('cursor-pointer')
fireEvent.click(badge!)
expect(onClick).toHaveBeenCalledTimes(1)
rerender(<HeaderBillingBtn onClick={onClick} isDisplayOnly />)
expect(screen.getByText('pro').closest('div')).toHaveClass('cursor-default')
fireEvent.click(screen.getByText('pro').closest('div')!)
expect(onClick).toHaveBeenCalledTimes(1)
})
})

View File

@ -0,0 +1,44 @@
import { render } from '@testing-library/react'
import PartnerStack from './index'
let isCloudEdition = true
const saveOrUpdate = vi.fn()
const bind = vi.fn()
vi.mock('@/config', () => ({
get IS_CLOUD_EDITION() {
return isCloudEdition
},
}))
vi.mock('./use-ps-info', () => ({
__esModule: true,
default: () => ({
saveOrUpdate,
bind,
}),
}))
describe('PartnerStack', () => {
beforeEach(() => {
vi.clearAllMocks()
isCloudEdition = true
})
it('does not call partner stack helpers when not in cloud edition', () => {
isCloudEdition = false
render(<PartnerStack />)
expect(saveOrUpdate).not.toHaveBeenCalled()
expect(bind).not.toHaveBeenCalled()
})
it('calls saveOrUpdate and bind once when running in cloud edition', () => {
render(<PartnerStack />)
expect(saveOrUpdate).toHaveBeenCalledTimes(1)
expect(bind).toHaveBeenCalledTimes(1)
})
})

View File

@ -0,0 +1,197 @@
import { act, renderHook } from '@testing-library/react'
import { PARTNER_STACK_CONFIG } from '@/config'
import usePSInfo from './use-ps-info'
let searchParamsValues: Record<string, string | null> = {}
const setSearchParams = (values: Record<string, string | null>) => {
searchParamsValues = values
}
type PartnerStackGlobal = typeof globalThis & {
__partnerStackCookieMocks?: {
get: ReturnType<typeof vi.fn>
set: ReturnType<typeof vi.fn>
remove: ReturnType<typeof vi.fn>
}
__partnerStackMutateAsync?: ReturnType<typeof vi.fn>
}
function getPartnerStackGlobal(): PartnerStackGlobal {
return globalThis as PartnerStackGlobal
}
const ensureCookieMocks = () => {
const globals = getPartnerStackGlobal()
if (!globals.__partnerStackCookieMocks)
throw new Error('Cookie mocks not initialized')
return globals.__partnerStackCookieMocks
}
const ensureMutateAsync = () => {
const globals = getPartnerStackGlobal()
if (!globals.__partnerStackMutateAsync)
throw new Error('Mutate mock not initialized')
return globals.__partnerStackMutateAsync
}
vi.mock('js-cookie', () => {
const get = vi.fn()
const set = vi.fn()
const remove = vi.fn()
const globals = getPartnerStackGlobal()
globals.__partnerStackCookieMocks = { get, set, remove }
const cookieApi = { get, set, remove }
return {
__esModule: true,
default: cookieApi,
get,
set,
remove,
}
})
vi.mock('next/navigation', () => ({
useSearchParams: () => ({
get: (key: string) => searchParamsValues[key] ?? null,
}),
}))
vi.mock('@/service/use-billing', () => {
const mutateAsync = vi.fn()
const globals = getPartnerStackGlobal()
globals.__partnerStackMutateAsync = mutateAsync
return {
useBindPartnerStackInfo: () => ({
mutateAsync,
}),
}
})
describe('usePSInfo', () => {
const originalLocationDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'location')
beforeAll(() => {
Object.defineProperty(globalThis, 'location', {
value: { hostname: 'cloud.dify.ai' },
configurable: true,
})
})
beforeEach(() => {
setSearchParams({})
const { get, set, remove } = ensureCookieMocks()
get.mockReset()
set.mockReset()
remove.mockReset()
const mutate = ensureMutateAsync()
mutate.mockReset()
mutate.mockResolvedValue(undefined)
get.mockReturnValue('{}')
})
afterAll(() => {
if (originalLocationDescriptor)
Object.defineProperty(globalThis, 'location', originalLocationDescriptor)
})
it('saves partner info when query params change', () => {
const { get, set } = ensureCookieMocks()
get.mockReturnValue(JSON.stringify({ partnerKey: 'old', clickId: 'old-click' }))
setSearchParams({
ps_partner_key: 'new-partner',
ps_xid: 'new-click',
})
const { result } = renderHook(() => usePSInfo())
expect(result.current.psPartnerKey).toBe('new-partner')
expect(result.current.psClickId).toBe('new-click')
act(() => {
result.current.saveOrUpdate()
})
expect(set).toHaveBeenCalledWith(
PARTNER_STACK_CONFIG.cookieName,
JSON.stringify({
partnerKey: 'new-partner',
clickId: 'new-click',
}),
{
expires: PARTNER_STACK_CONFIG.saveCookieDays,
path: '/',
domain: '.dify.ai',
},
)
})
it('does not overwrite cookie when params do not change', () => {
setSearchParams({
ps_partner_key: 'existing',
ps_xid: 'existing-click',
})
const { get } = ensureCookieMocks()
get.mockReturnValue(JSON.stringify({
partnerKey: 'existing',
clickId: 'existing-click',
}))
const { result } = renderHook(() => usePSInfo())
act(() => {
result.current.saveOrUpdate()
})
const { set } = ensureCookieMocks()
expect(set).not.toHaveBeenCalled()
})
it('binds partner info and clears cookie once', async () => {
setSearchParams({
ps_partner_key: 'bind-partner',
ps_xid: 'bind-click',
})
const { result } = renderHook(() => usePSInfo())
const mutate = ensureMutateAsync()
const { remove } = ensureCookieMocks()
await act(async () => {
await result.current.bind()
})
expect(mutate).toHaveBeenCalledWith({
partnerKey: 'bind-partner',
clickId: 'bind-click',
})
expect(remove).toHaveBeenCalledWith(PARTNER_STACK_CONFIG.cookieName, {
path: '/',
domain: '.dify.ai',
})
await act(async () => {
await result.current.bind()
})
expect(mutate).toHaveBeenCalledTimes(1)
})
it('still removes cookie when bind fails with status 400', async () => {
const mutate = ensureMutateAsync()
mutate.mockRejectedValueOnce({ status: 400 })
setSearchParams({
ps_partner_key: 'bind-partner',
ps_xid: 'bind-click',
})
const { result } = renderHook(() => usePSInfo())
await act(async () => {
await result.current.bind()
})
const { remove } = ensureCookieMocks()
expect(remove).toHaveBeenCalledWith(PARTNER_STACK_CONFIG.cookieName, {
path: '/',
domain: '.dify.ai',
})
})
})

View File

@ -0,0 +1,130 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { EDUCATION_VERIFYING_LOCALSTORAGE_ITEM } from '@/app/education-apply/constants'
import { Plan } from '../type'
import PlanComp from './index'
let currentPath = '/billing'
const push = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: () => ({ push }),
usePathname: () => currentPath,
}))
const setShowAccountSettingModalMock = vi.fn()
vi.mock('@/context/modal-context', () => ({
// eslint-disable-next-line ts/no-explicit-any
useModalContextSelector: (selector: any) => selector({
setShowAccountSettingModal: setShowAccountSettingModalMock,
}),
}))
const providerContextMock = vi.fn()
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => providerContextMock(),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
userProfile: { email: 'user@example.com' },
isCurrentWorkspaceManager: true,
}),
}))
const mutateAsyncMock = vi.fn()
let isPending = false
vi.mock('@/service/use-education', () => ({
useEducationVerify: () => ({
mutateAsync: mutateAsyncMock,
isPending,
}),
}))
const verifyStateModalMock = vi.fn(props => (
<div data-testid="verify-modal" data-is-show={props.isShow ? 'true' : 'false'}>
{props.isShow ? 'visible' : 'hidden'}
</div>
))
vi.mock('@/app/education-apply/verify-state-modal', () => ({
__esModule: true,
// eslint-disable-next-line ts/no-explicit-any
default: (props: any) => verifyStateModalMock(props),
}))
vi.mock('../upgrade-btn', () => ({
__esModule: true,
default: () => <button data-testid="plan-upgrade-btn" type="button">Upgrade</button>,
}))
describe('PlanComp', () => {
const planMock = {
type: Plan.professional,
usage: {
teamMembers: 4,
documentsUploadQuota: 3,
vectorSpace: 8,
annotatedResponse: 5,
triggerEvents: 60,
apiRateLimit: 100,
},
total: {
teamMembers: 10,
documentsUploadQuota: 20,
vectorSpace: 10,
annotatedResponse: 500,
triggerEvents: 100,
apiRateLimit: 200,
},
reset: {
triggerEvents: 2,
apiRateLimit: 1,
},
}
beforeEach(() => {
vi.clearAllMocks()
currentPath = '/billing'
isPending = false
providerContextMock.mockReturnValue({
plan: planMock,
enableEducationPlan: true,
allowRefreshEducationVerify: false,
isEducationAccount: false,
})
mutateAsyncMock.mockReset()
mutateAsyncMock.mockResolvedValue({ token: 'token' })
})
it('renders plan info and handles education verify success', async () => {
render(<PlanComp loc="billing-page" />)
expect(screen.getByText('billing.plans.professional.name')).toBeInTheDocument()
expect(screen.getByTestId('plan-upgrade-btn')).toBeInTheDocument()
const verifyBtn = screen.getByText('education.toVerified')
fireEvent.click(verifyBtn)
await waitFor(() => expect(mutateAsyncMock).toHaveBeenCalled())
await waitFor(() => expect(push).toHaveBeenCalledWith('/education-apply?token=token'))
expect(localStorage.removeItem).toHaveBeenCalledWith(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)
})
it('shows modal when education verify fails', async () => {
mutateAsyncMock.mockRejectedValueOnce(new Error('boom'))
render(<PlanComp loc="billing-page" />)
const verifyBtn = screen.getByText('education.toVerified')
fireEvent.click(verifyBtn)
await waitFor(() => expect(mutateAsyncMock).toHaveBeenCalled())
await waitFor(() => expect(screen.getByTestId('verify-modal').getAttribute('data-is-show')).toBe('true'))
})
it('resets modal context when on education apply path', () => {
currentPath = '/education-apply/setup'
render(<PlanComp loc="billing-page" />)
expect(setShowAccountSettingModalMock).toHaveBeenCalledWith(null)
})
})

View File

@ -0,0 +1,25 @@
import { render, screen } from '@testing-library/react'
import ProgressBar from './index'
describe('ProgressBar', () => {
it('renders with provided percent and color', () => {
render(<ProgressBar percent={42} color="bg-test-color" />)
const bar = screen.getByTestId('billing-progress-bar')
expect(bar).toHaveClass('bg-test-color')
expect(bar.getAttribute('style')).toContain('width: 42%')
})
it('caps width at 100% when percent exceeds max', () => {
render(<ProgressBar percent={150} color="bg-test-color" />)
const bar = screen.getByTestId('billing-progress-bar')
expect(bar.getAttribute('style')).toContain('width: 100%')
})
it('uses the default color when no color prop is provided', () => {
render(<ProgressBar percent={20} color={undefined as unknown as string} />)
expect(screen.getByTestId('billing-progress-bar')).toHaveClass('#2970FF')
})
})

View File

@ -0,0 +1,70 @@
import { render, screen } from '@testing-library/react'
import TriggerEventsLimitModal from './index'
const mockOnClose = vi.fn()
const mockOnUpgrade = vi.fn()
const planUpgradeModalMock = vi.fn((props: { show: boolean, title: string, description: string, extraInfo?: React.ReactNode, onClose: () => void, onUpgrade: () => void }) => (
<div
data-testid="plan-upgrade-modal"
data-show={props.show}
data-title={props.title}
data-description={props.description}
>
{props.extraInfo}
</div>
))
vi.mock('@/app/components/billing/plan-upgrade-modal', () => ({
__esModule: true,
// eslint-disable-next-line ts/no-explicit-any
default: (props: any) => planUpgradeModalMock(props),
}))
describe('TriggerEventsLimitModal', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('passes the trigger usage props to the upgrade modal', () => {
render(
<TriggerEventsLimitModal
show
onClose={mockOnClose}
onUpgrade={mockOnUpgrade}
usage={12}
total={20}
resetInDays={5}
/>,
)
const modal = screen.getByTestId('plan-upgrade-modal')
expect(modal.getAttribute('data-show')).toBe('true')
expect(modal.getAttribute('data-title')).toContain('billing.triggerLimitModal.title')
expect(modal.getAttribute('data-description')).toContain('billing.triggerLimitModal.description')
expect(planUpgradeModalMock).toHaveBeenCalled()
const passedProps = planUpgradeModalMock.mock.calls[0][0]
expect(passedProps.onClose).toBe(mockOnClose)
expect(passedProps.onUpgrade).toBe(mockOnUpgrade)
expect(screen.getByText('billing.triggerLimitModal.usageTitle')).toBeInTheDocument()
expect(screen.getByText('12')).toBeInTheDocument()
expect(screen.getByText('20')).toBeInTheDocument()
})
it('renders even when trigger modal is hidden', () => {
render(
<TriggerEventsLimitModal
show={false}
onClose={mockOnClose}
onUpgrade={mockOnUpgrade}
usage={0}
total={0}
/>,
)
expect(planUpgradeModalMock).toHaveBeenCalled()
expect(screen.getByTestId('plan-upgrade-modal').getAttribute('data-show')).toBe('false')
})
})

View File

@ -0,0 +1,35 @@
import { render, screen } from '@testing-library/react'
import { defaultPlan } from '../config'
import AppsInfo from './apps-info'
const appsUsage = 7
const appsTotal = 15
const mockPlan = {
...defaultPlan,
usage: {
...defaultPlan.usage,
buildApps: appsUsage,
},
total: {
...defaultPlan.total,
buildApps: appsTotal,
},
}
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
plan: mockPlan,
}),
}))
describe('AppsInfo', () => {
it('renders build apps usage information with context data', () => {
render(<AppsInfo className="apps-info-class" />)
expect(screen.getByText('billing.usagePage.buildApps')).toBeInTheDocument()
expect(screen.getByText(`${appsUsage}`)).toBeInTheDocument()
expect(screen.getByText(`${appsTotal}`)).toBeInTheDocument()
expect(screen.getByText('billing.usagePage.buildApps').closest('.apps-info-class')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,114 @@
import { render, screen } from '@testing-library/react'
import { NUM_INFINITE } from '../config'
import UsageInfo from './index'
const TestIcon = () => <span data-testid="usage-icon" />
describe('UsageInfo', () => {
it('renders the metric with a suffix unit and tooltip text', () => {
render(
<UsageInfo
Icon={TestIcon}
name="Apps"
usage={30}
total={100}
unit="GB"
tooltip="tooltip text"
/>,
)
expect(screen.getByTestId('usage-icon')).toBeInTheDocument()
expect(screen.getByText('Apps')).toBeInTheDocument()
expect(screen.getByText('30')).toBeInTheDocument()
expect(screen.getByText('100')).toBeInTheDocument()
expect(screen.getByText('GB')).toBeInTheDocument()
})
it('renders inline unit when unitPosition is inline', () => {
render(
<UsageInfo
Icon={TestIcon}
name="Storage"
usage={20}
total={100}
unit="GB"
unitPosition="inline"
/>,
)
expect(screen.getByText('100GB')).toBeInTheDocument()
})
it('shows reset hint text instead of the unit when resetHint is provided', () => {
const resetHint = 'Resets in 3 days'
render(
<UsageInfo
Icon={TestIcon}
name="Storage"
usage={20}
total={100}
unit="GB"
resetHint={resetHint}
/>,
)
expect(screen.getByText(resetHint)).toBeInTheDocument()
expect(screen.queryByText('GB')).not.toBeInTheDocument()
})
it('displays unlimited text when total is infinite', () => {
render(
<UsageInfo
Icon={TestIcon}
name="Storage"
usage={10}
total={NUM_INFINITE}
unit="GB"
/>,
)
expect(screen.getByText('billing.plansCommon.unlimited')).toBeInTheDocument()
})
it('applies warning color when usage is close to the limit', () => {
render(
<UsageInfo
Icon={TestIcon}
name="Storage"
usage={85}
total={100}
/>,
)
const progressBar = screen.getByTestId('billing-progress-bar')
expect(progressBar).toHaveClass('bg-components-progress-warning-progress')
})
it('applies error color when usage exceeds the limit', () => {
render(
<UsageInfo
Icon={TestIcon}
name="Storage"
usage={120}
total={100}
/>,
)
const progressBar = screen.getByTestId('billing-progress-bar')
expect(progressBar).toHaveClass('bg-components-progress-error-progress')
})
it('does not render the icon when hideIcon is true', () => {
render(
<UsageInfo
Icon={TestIcon}
name="Storage"
usage={5}
total={100}
hideIcon
/>,
)
expect(screen.queryByTestId('usage-icon')).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,58 @@
import { render, screen } from '@testing-library/react'
import VectorSpaceFull from './index'
type VectorProviderGlobal = typeof globalThis & {
__vectorProviderContext?: ReturnType<typeof vi.fn>
}
function getVectorGlobal(): VectorProviderGlobal {
return globalThis as VectorProviderGlobal
}
vi.mock('@/context/provider-context', () => {
const mock = vi.fn()
getVectorGlobal().__vectorProviderContext = mock
return {
useProviderContext: () => mock(),
}
})
vi.mock('../upgrade-btn', () => ({
__esModule: true,
default: () => <button data-testid="vector-upgrade-btn" type="button">Upgrade</button>,
}))
describe('VectorSpaceFull', () => {
const planMock = {
type: 'team',
usage: {
vectorSpace: 8,
},
total: {
vectorSpace: 10,
},
}
beforeEach(() => {
vi.clearAllMocks()
const globals = getVectorGlobal()
globals.__vectorProviderContext?.mockReturnValue({
plan: planMock,
})
})
it('renders tip text and upgrade button', () => {
render(<VectorSpaceFull />)
expect(screen.getByText('billing.vectorSpace.fullTip')).toBeInTheDocument()
expect(screen.getByText('billing.vectorSpace.fullSolution')).toBeInTheDocument()
expect(screen.getByTestId('vector-upgrade-btn')).toBeInTheDocument()
})
it('shows vector usage and total', () => {
render(<VectorSpaceFull />)
expect(screen.getByText('8')).toBeInTheDocument()
expect(screen.getByText('10MB')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,91 @@
import type { Member } from '@/models/common'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { vi } from 'vitest'
import { ToastContext } from '@/app/components/base/toast'
import Operation from './index'
const mockUpdateMemberRole = vi.fn()
const mockDeleteMemberOrCancelInvitation = vi.fn()
vi.mock('@/service/common', () => ({
deleteMemberOrCancelInvitation: () => mockDeleteMemberOrCancelInvitation(),
updateMemberRole: () => mockUpdateMemberRole(),
}))
const mockUseProviderContext = vi.fn(() => ({
datasetOperatorEnabled: false,
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => mockUseProviderContext(),
}))
const defaultMember: Member = {
id: 'member-id',
name: 'Test Member',
email: 'test@example.com',
avatar: '',
avatar_url: null,
status: 'active',
role: 'editor',
last_login_at: '',
last_active_at: '',
created_at: '',
}
const renderOperation = (propsOverride: Partial<Member> = {}, operatorRole = 'owner', onOperate?: () => void) => {
const mergedMember = { ...defaultMember, ...propsOverride }
return render(
<ToastContext.Provider value={{ notify: vi.fn(), close: vi.fn() }}>
<Operation member={mergedMember} operatorRole={operatorRole} onOperate={onOperate ?? vi.fn()} />
</ToastContext.Provider>,
)
}
describe('Operation', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseProviderContext.mockReturnValue({ datasetOperatorEnabled: false })
})
it('renders the current role label', () => {
renderOperation()
expect(screen.getByText('common.members.editor')).toBeInTheDocument()
})
it('shows dataset operator option when the feature flag is enabled', async () => {
mockUseProviderContext.mockReturnValue({ datasetOperatorEnabled: true })
renderOperation()
fireEvent.click(screen.getByText('common.members.editor'))
expect(await screen.findByText('common.members.datasetOperator')).toBeInTheDocument()
})
it('calls updateMemberRole and onOperate when selecting another role', async () => {
const onOperate = vi.fn()
renderOperation({}, 'owner', onOperate)
fireEvent.click(screen.getByText('common.members.editor'))
fireEvent.click(await screen.findByText('common.members.normal'))
await waitFor(() => {
expect(mockUpdateMemberRole).toHaveBeenCalled()
expect(onOperate).toHaveBeenCalled()
})
})
it('calls deleteMemberOrCancelInvitation when removing the member', async () => {
const onOperate = vi.fn()
renderOperation({}, 'owner', onOperate)
fireEvent.click(screen.getByText('common.members.editor'))
fireEvent.click(await screen.findByText('common.members.removeFromTeam'))
await waitFor(() => {
expect(mockDeleteMemberOrCancelInvitation).toHaveBeenCalled()
expect(onOperate).toHaveBeenCalled()
})
})
})

View File

@ -1,10 +1,14 @@
'use client'
import type { Member } from '@/models/common'
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'
import { CheckIcon, ChevronDownIcon } from '@heroicons/react/24/outline'
import { Fragment, useMemo } from 'react'
import { memo, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { ToastContext } from '@/app/components/base/toast'
import { useProviderContext } from '@/context/provider-context'
import { deleteMemberOrCancelInvitation, updateMemberRole } from '@/service/common'
@ -21,6 +25,7 @@ const Operation = ({
operatorRole,
onOperate,
}: IOperationProps) => {
const [open, setOpen] = useState(false)
const { t } = useTranslation()
const { datasetOperatorEnabled } = useProviderContext()
const RoleMap = {
@ -51,6 +56,7 @@ const Operation = ({
const { notify } = useContext(ToastContext)
const toHump = (name: string) => name.replace(/_(\w)/g, (all, letter) => letter.toUpperCase())
const handleDeleteMemberOrCancelInvitation = async () => {
setOpen(false)
try {
await deleteMemberOrCancelInvitation({ url: `/workspaces/current/members/${member.id}` })
onOperate()
@ -61,6 +67,7 @@ const Operation = ({
}
}
const handleUpdateMemberRole = async (role: string) => {
setOpen(false)
try {
await updateMemberRole({
url: `/workspaces/current/members/${member.id}/update-role`,
@ -75,63 +82,50 @@ const Operation = ({
}
return (
<Menu as="div" className="relative h-full w-full">
{
({ open }) => (
<>
<MenuButton className={cn('system-sm-regular group flex h-full w-full cursor-pointer items-center justify-between px-3 text-text-secondary hover:bg-state-base-hover', open && 'bg-state-base-hover')}>
{RoleMap[member.role] || RoleMap.normal}
<ChevronDownIcon className={cn('h-4 w-4 group-hover:block', open ? 'block' : 'hidden')} />
</MenuButton>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<MenuItems
className={cn('absolute right-0 top-[52px] z-10 origin-top-right rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm')}
>
<div className="p-1">
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="bottom-end"
offset={{ mainAxis: 4 }}
>
<PortalToFollowElemTrigger asChild onClick={() => setOpen(prev => !prev)}>
<div className={cn('system-sm-regular group flex h-full w-full cursor-pointer items-center justify-between px-3 text-text-secondary hover:bg-state-base-hover', open && 'bg-state-base-hover')}>
{RoleMap[member.role] || RoleMap.normal}
<ChevronDownIcon className={cn('h-4 w-4 shrink-0 group-hover:block', open ? 'block' : 'hidden')} />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[999]">
<div className={cn('inline-flex flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm')}>
<div className="p-1">
{
roleList.map(role => (
<div key={role} className="flex cursor-pointer rounded-lg px-3 py-2 hover:bg-state-base-hover" onClick={() => handleUpdateMemberRole(role)}>
{
roleList.map(role => (
<MenuItem key={role}>
<div className="flex cursor-pointer rounded-lg px-3 py-2 hover:bg-state-base-hover" onClick={() => handleUpdateMemberRole(role)}>
{
role === member.role
? <CheckIcon className="mr-1 mt-[2px] h-4 w-4 text-text-accent" />
: <div className="mr-1 mt-[2px] h-4 w-4 text-text-accent" />
}
<div>
<div className="system-sm-semibold whitespace-nowrap text-text-secondary">{t(`common.members.${toHump(role)}` as any)}</div>
<div className="system-xs-regular whitespace-nowrap text-text-tertiary">{t(`common.members.${toHump(role)}Tip` as any)}</div>
</div>
</div>
</MenuItem>
))
role === member.role
? <CheckIcon className="mr-1 mt-[2px] h-4 w-4 text-text-accent" />
: <div className="mr-1 mt-[2px] h-4 w-4 text-text-accent" />
}
</div>
<MenuItem>
<div className="border-t border-divider-subtle p-1">
<div className="flex cursor-pointer rounded-lg px-3 py-2 hover:bg-state-base-hover" onClick={handleDeleteMemberOrCancelInvitation}>
<div className="mr-1 mt-[2px] h-4 w-4 text-text-accent" />
<div>
<div className="system-sm-semibold whitespace-nowrap text-text-secondary">{t('common.members.removeFromTeam')}</div>
<div className="system-xs-regular whitespace-nowrap text-text-tertiary">{t('common.members.removeFromTeamTip')}</div>
</div>
</div>
<div>
<div className="system-sm-semibold whitespace-nowrap text-text-secondary">{t(`common.members.${toHump(role)}` as any)}</div>
<div className="system-xs-regular whitespace-nowrap text-text-tertiary">{t(`common.members.${toHump(role)}Tip` as any)}</div>
</div>
</MenuItem>
</MenuItems>
</Transition>
</>
)
}
</Menu>
</div>
))
}
</div>
<div className="border-t border-divider-subtle p-1">
<div className="flex cursor-pointer rounded-lg px-3 py-2 hover:bg-state-base-hover" onClick={handleDeleteMemberOrCancelInvitation}>
<div className="mr-1 mt-[2px] h-4 w-4 text-text-accent" />
<div>
<div className="system-sm-semibold whitespace-nowrap text-text-secondary">{t('common.members.removeFromTeam')}</div>
<div className="system-xs-regular whitespace-nowrap text-text-tertiary">{t('common.members.removeFromTeamTip')}</div>
</div>
</div>
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default Operation
export default memo(Operation)

View File

@ -26,7 +26,7 @@ import DifyLogo from '@/app/components/base/logo/dify-logo'
import Toast from '@/app/components/base/toast'
import Res from '@/app/components/share/text-generation/result'
import RunOnce from '@/app/components/share/text-generation/run-once'
import { appDefaultIconBackground, DEFAULT_VALUE_MAX_LEN } from '@/config'
import { appDefaultIconBackground, BATCH_CONCURRENCY, DEFAULT_VALUE_MAX_LEN } from '@/config'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useWebAppStore } from '@/context/web-app-context'
import { useAppFavicon } from '@/hooks/use-app-favicon'
@ -43,7 +43,7 @@ import MenuDropdown from './menu-dropdown'
import RunBatch from './run-batch'
import ResDownload from './run-batch/res-download'
const GROUP_SIZE = 5 // to avoid RPM(Request per minute) limit. The group task finished then the next group.
const GROUP_SIZE = BATCH_CONCURRENCY // to avoid RPM(Request per minute) limit. The group task finished then the next group.
enum TaskStatus {
pending = 'pending',
running = 'running',

View File

@ -0,0 +1,60 @@
import type { Credential } from '@/app/components/tools/types'
import { act, fireEvent, render, screen } from '@testing-library/react'
import { AuthHeaderPrefix, AuthType } from '@/app/components/tools/types'
import ConfigCredential from './config-credentials'
describe('ConfigCredential', () => {
const baseCredential: Credential = {
auth_type: AuthType.none,
}
const mockOnChange = vi.fn()
const mockOnHide = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
it('renders and calls onHide when cancel is pressed', async () => {
await act(async () => {
render(
<ConfigCredential
credential={baseCredential}
onChange={mockOnChange}
onHide={mockOnHide}
/>,
)
})
fireEvent.click(screen.getByText('common.operation.cancel'))
expect(mockOnHide).toHaveBeenCalledTimes(1)
expect(mockOnChange).not.toHaveBeenCalled()
})
it('allows selecting apiKeyHeader and submits the new credential', async () => {
await act(async () => {
render(
<ConfigCredential
credential={baseCredential}
onChange={mockOnChange}
onHide={mockOnHide}
/>,
)
})
fireEvent.click(screen.getByText('tools.createTool.authMethod.types.api_key_header'))
const headerInput = screen.getByPlaceholderText('tools.createTool.authMethod.types.apiKeyPlaceholder')
const valueInput = screen.getByPlaceholderText('tools.createTool.authMethod.types.apiValuePlaceholder')
fireEvent.change(headerInput, { target: { value: 'X-Auth' } })
fireEvent.change(valueInput, { target: { value: 'sEcReT' } })
fireEvent.click(screen.getByText('common.operation.save'))
expect(mockOnChange).toHaveBeenCalledWith({
auth_type: AuthType.apiKeyHeader,
api_key_header: 'X-Auth',
api_key_header_prefix: AuthHeaderPrefix.custom,
api_key_value: 'sEcReT',
})
expect(mockOnHide).toHaveBeenCalled()
})
})

View File

@ -0,0 +1,55 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { importSchemaFromURL } from '@/service/tools'
import Toast from '../../base/toast'
import examples from './examples'
import GetSchema from './get-schema'
vi.mock('@/service/tools', () => ({
importSchemaFromURL: vi.fn(),
}))
const importSchemaFromURLMock = vi.mocked(importSchemaFromURL)
describe('GetSchema', () => {
const notifySpy = vi.spyOn(Toast, 'notify')
const mockOnChange = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
notifySpy.mockClear()
importSchemaFromURLMock.mockReset()
render(<GetSchema onChange={mockOnChange} />)
})
it('shows an error when the URL is not http', () => {
fireEvent.click(screen.getByText('tools.createTool.importFromUrl'))
const input = screen.getByPlaceholderText('tools.createTool.importFromUrlPlaceHolder')
// eslint-disable-next-line sonarjs/no-clear-text-protocols
fireEvent.change(input, { target: { value: 'ftp://invalid' } })
fireEvent.click(screen.getByText('common.operation.ok'))
expect(notifySpy).toHaveBeenCalledWith({
type: 'error',
message: 'tools.createTool.urlError',
})
})
it('imports schema from url when valid', async () => {
fireEvent.click(screen.getByText('tools.createTool.importFromUrl'))
const input = screen.getByPlaceholderText('tools.createTool.importFromUrlPlaceHolder')
fireEvent.change(input, { target: { value: 'https://example.com' } })
importSchemaFromURLMock.mockResolvedValueOnce({ schema: 'result-schema' })
fireEvent.click(screen.getByText('common.operation.ok'))
await waitFor(() => {
expect(mockOnChange).toHaveBeenCalledWith('result-schema')
})
})
it('selects example schema when example option clicked', () => {
fireEvent.click(screen.getByText('tools.createTool.examples'))
fireEvent.click(screen.getByText(`tools.createTool.exampleOptions.${examples[0].key}`))
expect(mockOnChange).toHaveBeenCalledWith(examples[0].content)
})
})

View File

@ -0,0 +1,154 @@
import type { ModalContextState } from '@/context/modal-context'
import type { ProviderContextState } from '@/context/provider-context'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import Toast from '@/app/components/base/toast'
import { Plan } from '@/app/components/billing/type'
import { parseParamsSchema } from '@/service/tools'
import EditCustomCollectionModal from './index'
vi.mock('ahooks', async () => {
const actual = await vi.importActual<typeof import('ahooks')>('ahooks')
return {
...actual,
useDebounce: (value: unknown) => value,
}
})
vi.mock('@/service/tools', () => ({
parseParamsSchema: vi.fn(),
}))
const parseParamsSchemaMock = vi.mocked(parseParamsSchema)
const mockSetShowPricingModal = vi.fn()
const mockSetShowAccountSettingModal = vi.fn()
vi.mock('@/context/modal-context', () => ({
useModalContext: (): ModalContextState => ({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
setShowApiBasedExtensionModal: vi.fn(),
setShowModerationSettingModal: vi.fn(),
setShowExternalDataToolModal: vi.fn(),
setShowPricingModal: mockSetShowPricingModal,
setShowAnnotationFullModal: vi.fn(),
setShowModelModal: vi.fn(),
setShowExternalKnowledgeAPIModal: vi.fn(),
setShowModelLoadBalancingModal: vi.fn(),
setShowOpeningModal: vi.fn(),
setShowUpdatePluginModal: vi.fn(),
setShowEducationExpireNoticeModal: vi.fn(),
setShowTriggerEventsLimitModal: vi.fn(),
}),
}))
const mockUseProviderContext = vi.fn()
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => mockUseProviderContext(),
}))
vi.mock('@/context/i18n', async () => {
const actual = await vi.importActual<typeof import('@/context/i18n')>('@/context/i18n')
return {
...actual,
useDocLink: () => (path?: string) => `https://docs.example.com${path ?? ''}`,
}
})
describe('EditCustomCollectionModal', () => {
const mockOnHide = vi.fn()
const mockOnAdd = vi.fn()
const mockOnEdit = vi.fn()
const mockOnRemove = vi.fn()
const toastNotifySpy = vi.spyOn(Toast, 'notify')
beforeEach(() => {
vi.clearAllMocks()
toastNotifySpy.mockClear()
parseParamsSchemaMock.mockResolvedValue({
parameters_schema: [],
schema_type: 'openapi',
})
mockUseProviderContext.mockReturnValue({
plan: {
type: Plan.sandbox,
},
enableBilling: false,
webappCopyrightEnabled: true,
} as ProviderContextState)
})
const renderModal = () => render(
<EditCustomCollectionModal
payload={undefined}
onHide={mockOnHide}
onAdd={mockOnAdd}
onEdit={mockOnEdit}
onRemove={mockOnRemove}
/>,
)
it('shows an error when the provider name is missing', async () => {
renderModal()
const schemaInput = screen.getByPlaceholderText('tools.createTool.schemaPlaceHolder')
fireEvent.change(schemaInput, { target: { value: '{}' } })
await waitFor(() => {
expect(parseParamsSchemaMock).toHaveBeenCalledWith('{}')
})
fireEvent.click(screen.getByText('common.operation.save'))
await waitFor(() => {
expect(toastNotifySpy).toHaveBeenCalledWith(expect.objectContaining({
message: 'common.errorMsg.fieldRequired:{"field":"tools.createTool.name"}',
type: 'error',
}))
})
expect(mockOnAdd).not.toHaveBeenCalled()
})
it('shows an error when the schema is missing', async () => {
renderModal()
const providerInput = screen.getByPlaceholderText('tools.createTool.toolNamePlaceHolder')
fireEvent.change(providerInput, { target: { value: 'provider' } })
fireEvent.click(screen.getByText('common.operation.save'))
await waitFor(() => {
expect(toastNotifySpy).toHaveBeenCalledWith(expect.objectContaining({
message: 'common.errorMsg.fieldRequired:{"field":"tools.createTool.schema"}',
type: 'error',
}))
})
expect(mockOnAdd).not.toHaveBeenCalled()
})
it('saves a valid custom collection', async () => {
renderModal()
const providerInput = screen.getByPlaceholderText('tools.createTool.toolNamePlaceHolder')
fireEvent.change(providerInput, { target: { value: 'provider' } })
const schemaInput = screen.getByPlaceholderText('tools.createTool.schemaPlaceHolder')
fireEvent.change(schemaInput, { target: { value: '{}' } })
await waitFor(() => {
expect(parseParamsSchemaMock).toHaveBeenCalledWith('{}')
})
await act(async () => {
fireEvent.click(screen.getByText('common.operation.save'))
})
await waitFor(() => {
expect(mockOnAdd).toHaveBeenCalledWith(expect.objectContaining({
provider: 'provider',
schema: '{}',
schema_type: 'openapi',
credentials: {
auth_type: 'none',
},
labels: [],
}))
expect(toastNotifySpy).not.toHaveBeenCalled()
})
})
})

View File

@ -0,0 +1,87 @@
import type { CustomCollectionBackend, CustomParamSchema } from '@/app/components/tools/types'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { AuthType } from '@/app/components/tools/types'
import I18n from '@/context/i18n'
import { testAPIAvailable } from '@/service/tools'
import TestApi from './test-api'
vi.mock('@/service/tools', () => ({
testAPIAvailable: vi.fn(),
}))
const testAPIAvailableMock = vi.mocked(testAPIAvailable)
describe('TestApi', () => {
const customCollection: CustomCollectionBackend = {
provider: 'custom',
credentials: {
auth_type: AuthType.none,
},
schema_type: 'openapi',
schema: '{ }',
icon: { background: '', content: '' },
privacy_policy: '',
custom_disclaimer: '',
id: 'test-id',
labels: [],
}
const tool: CustomParamSchema = {
operation_id: 'testOp',
summary: 'summary',
method: 'GET',
server_url: 'https://api.example.com',
parameters: [{
name: 'limit',
label: {
en_US: 'Limit',
zh_Hans: '限制',
},
// eslint-disable-next-line ts/no-explicit-any
} as any],
}
const renderTestApi = () => {
const providerValue = {
locale: 'en-US',
i18n: {},
setLocaleOnClient: vi.fn(),
}
return render(
<I18n.Provider value={providerValue as any}>
<TestApi
customCollection={customCollection}
tool={tool}
onHide={vi.fn()}
/>
</I18n.Provider>,
)
}
beforeEach(() => {
vi.clearAllMocks()
})
it('renders parameters and runs the API test', async () => {
testAPIAvailableMock.mockResolvedValueOnce({ result: 'ok' })
renderTestApi()
const parameterInput = screen.getAllByRole('textbox')[0]
fireEvent.change(parameterInput, { target: { value: '5' } })
fireEvent.click(screen.getByRole('button', { name: 'tools.test.title' }))
await waitFor(() => {
expect(testAPIAvailableMock).toHaveBeenCalledWith({
provider_name: customCollection.provider,
tool_name: tool.operation_id,
credentials: {
auth_type: AuthType.none,
},
schema_type: customCollection.schema_type,
schema: customCollection.schema,
parameters: {
limit: '5',
},
})
expect(screen.getByText('ok')).toBeInTheDocument()
})
})
})

View File

@ -188,8 +188,8 @@ const FeaturesTrigger = () => {
{isChatMode && (
<Button
className={cn(
'text-components-button-secondary-text',
theme === 'dark' && 'rounded-lg border border-black/5 bg-white/10 backdrop-blur-sm',
'rounded-lg border border-transparent text-components-button-secondary-text',
theme === 'dark' && 'border-black/5 bg-white/10 backdrop-blur-sm',
)}
onClick={handleShowFeatures}
>

View File

@ -23,8 +23,8 @@ const ChatVariableButton = ({ disabled }: { disabled: boolean }) => {
return (
<Button
className={cn(
'p-2',
theme === 'dark' && showChatVariablePanel && 'rounded-lg border border-black/5 bg-white/10 backdrop-blur-sm',
'rounded-lg border border-transparent p-2',
theme === 'dark' && showChatVariablePanel && 'border-black/5 bg-white/10 backdrop-blur-sm',
)}
disabled={disabled}
onClick={handleClick}

View File

@ -26,8 +26,8 @@ const EnvButton = ({ disabled }: { disabled: boolean }) => {
return (
<Button
className={cn(
'p-2',
theme === 'dark' && showEnvPanel && 'rounded-lg border border-black/5 bg-white/10 backdrop-blur-sm',
'rounded-lg border border-transparent p-2',
theme === 'dark' && showEnvPanel && 'border-black/5 bg-white/10 backdrop-blur-sm',
)}
variant="ghost"
disabled={disabled}

View File

@ -26,8 +26,8 @@ const GlobalVariableButton = ({ disabled }: { disabled: boolean }) => {
return (
<Button
className={cn(
'p-2',
theme === 'dark' && showGlobalVariablePanel && 'rounded-lg border border-black/5 bg-white/10 backdrop-blur-sm',
'rounded-lg border border-transparent p-2',
theme === 'dark' && showGlobalVariablePanel && 'border-black/5 bg-white/10 backdrop-blur-sm',
)}
disabled={disabled}
onClick={handleClick}

View File

@ -86,7 +86,8 @@ const HeaderInRestoring = ({
disabled={!currentVersion || currentVersion.version === WorkflowVersion.Draft}
variant="primary"
className={cn(
theme === 'dark' && 'rounded-lg border border-black/5 bg-white/10 backdrop-blur-sm',
'rounded-lg border border-transparent',
theme === 'dark' && 'border-black/5 bg-white/10 backdrop-blur-sm',
)}
>
{t('workflow.common.restore')}
@ -94,8 +95,8 @@ const HeaderInRestoring = ({
<Button
onClick={handleCancelRestore}
className={cn(
'text-components-button-secondary-accent-text',
theme === 'dark' && 'rounded-lg border border-black/5 bg-white/10 backdrop-blur-sm',
'rounded-lg border border-transparent text-components-button-secondary-accent-text',
theme === 'dark' && 'border-black/5 bg-white/10 backdrop-blur-sm',
)}
>
<div className="flex items-center gap-x-0.5">

View File

@ -61,8 +61,8 @@ const VersionHistoryButton: FC<VersionHistoryButtonProps> = ({
>
<Button
className={cn(
'p-2',
theme === 'dark' && 'rounded-lg border border-black/5 bg-white/10 backdrop-blur-sm',
'p-2 rounded-lg border border-transparent',
theme === 'dark' && 'border-black/5 bg-white/10 backdrop-blur-sm',
)}
onClick={handleViewVersionHistory}
>

View File

@ -63,6 +63,11 @@ export const useShortcuts = (): void => {
return !isEventTargetInputArea(e.target as HTMLElement)
}, [])
const shouldHandleCopy = useCallback(() => {
const selection = document.getSelection()
return !selection || selection.isCollapsed
}, [])
useKeyPress(['delete', 'backspace'], (e) => {
if (shouldHandleShortcut(e)) {
e.preventDefault()
@ -73,7 +78,7 @@ export const useShortcuts = (): void => {
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.c`, (e) => {
const { showDebugAndPreviewPanel } = workflowStore.getState()
if (shouldHandleShortcut(e) && !showDebugAndPreviewPanel) {
if (shouldHandleShortcut(e) && shouldHandleCopy() && !showDebugAndPreviewPanel) {
e.preventDefault()
handleNodesCopy()
}

View File

@ -195,9 +195,11 @@ export const Workflow: FC<WorkflowProps> = memo(({
const { nodesReadOnly } = useNodesReadOnly()
const { eventEmitter } = useEventEmitterContextContext()
const store = useStoreApi()
eventEmitter?.useSubscription((v: any) => {
if (v.type === WORKFLOW_DATA_UPDATE) {
setNodes(v.payload.nodes)
store.getState().setNodes(v.payload.nodes)
setEdges(v.payload.edges)
if (v.payload.viewport)
@ -359,7 +361,6 @@ export const Workflow: FC<WorkflowProps> = memo(({
}
}, [schemaTypeDefinitions, fetchInspectVars, isLoadedVars, vars, customTools, buildInTools, workflowTools, mcpTools, dataSourceList])
const store = useStoreApi()
if (process.env.NODE_ENV === 'development') {
store.getState().onError = (code, message) => {
if (code === '002')

View File

@ -62,8 +62,15 @@ const ConditionItem = ({
}, [onRemoveCondition, condition.id])
const currentMetadata = useMemo(() => {
// Try to match by metadata_id first (reliable reference)
if (condition.metadata_id) {
const found = metadataList.find(metadata => metadata.id === condition.metadata_id)
if (found)
return found
}
// Fallback to name matching for backward compatibility with old conditions
return metadataList.find(metadata => metadata.name === condition.name)
}, [metadataList, condition.name])
}, [metadataList, condition.metadata_id, condition.name])
const handleConditionOperatorChange = useCallback((operator: ComparisonOperator) => {
onUpdateCondition?.(

View File

@ -27,11 +27,17 @@ const MetadataTrigger = ({
useEffect(() => {
if (selectedDatasetsLoaded) {
conditions.forEach((condition) => {
if (!metadataList.find(metadata => metadata.name === condition.name))
// First try to match by metadata_id for reliable reference
const foundById = condition.metadata_id && metadataList.find(metadata => metadata.id === condition.metadata_id)
// Fallback to name matching only for backward compatibility with old conditions
const foundByName = !condition.metadata_id && metadataList.find(metadata => metadata.name === condition.name)
// Only remove condition if both metadata_id and name matching fail
if (!foundById && !foundByName)
handleRemoveCondition(condition.id)
})
}
}, [metadataList, handleRemoveCondition, selectedDatasetsLoaded])
}, [metadataFilteringConditions, metadataList, handleRemoveCondition, selectedDatasetsLoaded])
return (
<PortalToFollowElem

View File

@ -86,6 +86,7 @@ export enum MetadataFilteringVariableType {
export type MetadataFilteringCondition = {
id: string
name: string
metadata_id?: string
comparison_operator: ComparisonOperator
value?: string | number
}

View File

@ -305,7 +305,7 @@ const useConfig = (id: string, payload: KnowledgeRetrievalNodeType) => {
}))
}, [setInputs])
const handleAddCondition = useCallback<HandleAddCondition>(({ name, type }) => {
const handleAddCondition = useCallback<HandleAddCondition>(({ id, name, type }) => {
let operator: ComparisonOperator = ComparisonOperator.is
if (type === MetadataFilteringVariableType.number)
@ -313,6 +313,7 @@ const useConfig = (id: string, payload: KnowledgeRetrievalNodeType) => {
const newCondition = {
id: uuid4(),
metadata_id: id, // Save metadata.id for reliable reference
name,
comparison_operator: operator,
}

View File

@ -67,6 +67,7 @@ const LocaleLayout = async ({
[DatasetAttr.NEXT_PUBLIC_ZENDESK_FIELD_ID_EMAIL]: process.env.NEXT_PUBLIC_ZENDESK_FIELD_ID_EMAIL,
[DatasetAttr.NEXT_PUBLIC_ZENDESK_FIELD_ID_WORKSPACE_ID]: process.env.NEXT_PUBLIC_ZENDESK_FIELD_ID_WORKSPACE_ID,
[DatasetAttr.NEXT_PUBLIC_ZENDESK_FIELD_ID_PLAN]: process.env.NEXT_PUBLIC_ZENDESK_FIELD_ID_PLAN,
[DatasetAttr.DATA_PUBLIC_BATCH_CONCURRENCY]: process.env.NEXT_PUBLIC_BATCH_CONCURRENCY,
}
return (

View File

@ -164,6 +164,13 @@ const COOKIE_DOMAIN = getStringConfig(
DatasetAttr.DATA_PUBLIC_COOKIE_DOMAIN,
'',
).trim()
export const BATCH_CONCURRENCY = getNumberConfig(
process.env.NEXT_PUBLIC_BATCH_CONCURRENCY,
DatasetAttr.DATA_PUBLIC_BATCH_CONCURRENCY,
5, // default
)
export const CSRF_COOKIE_NAME = () => {
if (COOKIE_DOMAIN)
return 'csrf_token'

View File

@ -1,7 +1,6 @@
import type { Locale } from '.'
import type { KeyPrefix, Namespace } from './i18next-config'
import type { Namespace } from './i18next-config'
import { match } from '@formatjs/intl-localematcher'
import { camelCase } from 'es-toolkit/compat'
import { createInstance } from 'i18next'
import resourcesToBackend from 'i18next-resources-to-backend'
import Negotiator from 'negotiator'
@ -23,10 +22,11 @@ const initI18next = async (lng: Locale, ns: Namespace) => {
return i18nInstance
}
export async function getTranslation(lng: Locale, ns: Namespace) {
export async function getTranslation(lng: Locale, ns: Namespace, options: Record<string, any> = {}) {
const i18nextInstance = await initI18next(lng, ns)
return {
t: i18nextInstance.getFixedT(lng, 'translation', camelCase(ns) as KeyPrefix),
// @ts-expect-error types mismatch
t: i18nextInstance.getFixedT(lng, ns, options.keyPrefix),
i18n: i18nextInstance,
}
}

View File

@ -1,7 +1,7 @@
{
"name": "dify-web",
"type": "module",
"version": "1.11.1",
"version": "1.11.2",
"private": true,
"packageManager": "pnpm@10.26.1+sha512.664074abc367d2c9324fdc18037097ce0a8f126034160f709928e9e9f95d98714347044e5c3164d65bd5da6c59c6be362b107546292a8eecb7999196e5ce58fa",
"engines": {
@ -38,7 +38,8 @@
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"test:watch": "vitest --watch",
"analyze-component": "node testing/analyze-component.js",
"analyze-component": "node ./scripts/analyze-component.js",
"refactor-component": "node ./scripts/refactor-component.js",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"preinstall": "npx only-allow pnpm",

View File

@ -3,376 +3,13 @@
import { spawnSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import tsParser from '@typescript-eslint/parser'
import { Linter } from 'eslint'
import sonarPlugin from 'eslint-plugin-sonarjs'
// ============================================================================
// Simple Analyzer
// ============================================================================
class ComponentAnalyzer {
analyze(code, filePath, absolutePath) {
const resolvedPath = absolutePath ?? path.resolve(process.cwd(), filePath)
const fileName = path.basename(filePath, path.extname(filePath))
const lineCount = code.split('\n').length
// Calculate complexity metrics
const { total: rawComplexity, max: rawMaxComplexity } = this.calculateCognitiveComplexity(code)
const complexity = this.normalizeComplexity(rawComplexity)
const maxComplexity = this.normalizeComplexity(rawMaxComplexity)
// Count usage references (may take a few seconds)
const usageCount = this.countUsageReferences(filePath, resolvedPath)
// Calculate test priority
const priority = this.calculateTestPriority(complexity, usageCount)
return {
name: fileName.charAt(0).toUpperCase() + fileName.slice(1),
path: filePath,
type: this.detectType(filePath, code),
hasProps: code.includes('Props') || code.includes('interface'),
hasState: code.includes('useState') || code.includes('useReducer'),
hasEffects: code.includes('useEffect'),
hasCallbacks: code.includes('useCallback'),
hasMemo: code.includes('useMemo'),
hasEvents: /on[A-Z]\w+/.test(code),
hasRouter: code.includes('useRouter') || code.includes('usePathname'),
hasAPI: code.includes('service/') || code.includes('fetch(') || code.includes('useSWR'),
hasForwardRef: code.includes('forwardRef'),
hasComponentMemo: /React\.memo|memo\(/.test(code),
hasSuspense: code.includes('Suspense') || /\blazy\(/.test(code),
hasPortal: code.includes('createPortal'),
hasImperativeHandle: code.includes('useImperativeHandle'),
hasSWR: code.includes('useSWR'),
hasReactQuery: code.includes('useQuery') || code.includes('useMutation'),
hasAhooks: code.includes('from \'ahooks\''),
complexity,
maxComplexity,
rawComplexity,
rawMaxComplexity,
lineCount,
usageCount,
priority,
}
}
detectType(filePath, code) {
const normalizedPath = filePath.replace(/\\/g, '/')
if (normalizedPath.includes('/hooks/'))
return 'hook'
if (normalizedPath.includes('/utils/'))
return 'util'
if (/\/page\.(t|j)sx?$/.test(normalizedPath))
return 'page'
if (/\/layout\.(t|j)sx?$/.test(normalizedPath))
return 'layout'
if (/\/providers?\//.test(normalizedPath))
return 'provider'
// Dify-specific types
if (normalizedPath.includes('/components/base/'))
return 'base-component'
if (normalizedPath.includes('/context/'))
return 'context'
if (normalizedPath.includes('/store/'))
return 'store'
if (normalizedPath.includes('/service/'))
return 'service'
if (/use[A-Z]\w+/.test(code))
return 'component'
return 'component'
}
/**
* Calculate Cognitive Complexity using SonarJS ESLint plugin
* Reference: https://www.sonarsource.com/blog/5-clean-code-tips-for-reducing-cognitive-complexity/
*
* Returns raw (unnormalized) complexity values:
* - total: sum of all functions' complexity in the file
* - max: highest single function complexity in the file
*
* Raw Score Thresholds (per function):
* 0-15: Simple | 16-30: Medium | 31-50: Complex | 51+: Very Complex
*
* @returns {{ total: number, max: number }} raw total and max complexity
*/
calculateCognitiveComplexity(code) {
const linter = new Linter()
const baseConfig = {
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
ecmaFeatures: { jsx: true },
},
},
plugins: { sonarjs: sonarPlugin },
}
try {
// Get total complexity using 'metric' option (more stable)
const totalConfig = {
...baseConfig,
rules: { 'sonarjs/cognitive-complexity': ['error', 0, 'metric'] },
}
const totalMessages = linter.verify(code, totalConfig)
const totalMsg = totalMessages.find(
msg => msg.ruleId === 'sonarjs/cognitive-complexity'
&& msg.messageId === 'fileComplexity',
)
const total = totalMsg ? Number.parseInt(totalMsg.message, 10) : 0
// Get max function complexity by analyzing each function
const maxConfig = {
...baseConfig,
rules: { 'sonarjs/cognitive-complexity': ['error', 0] },
}
const maxMessages = linter.verify(code, maxConfig)
let max = 0
const complexityPattern = /reduce its Cognitive Complexity from (\d+)/
maxMessages.forEach((msg) => {
if (msg.ruleId === 'sonarjs/cognitive-complexity') {
const match = msg.message.match(complexityPattern)
if (match && match[1])
max = Math.max(max, Number.parseInt(match[1], 10))
}
})
return { total, max }
}
catch {
return { total: 0, max: 0 }
}
}
/**
* Normalize cognitive complexity to 0-100 scale
*
* Mapping (aligned with SonarJS thresholds):
* Raw 0-15 (Simple) -> Normalized 0-25
* Raw 16-30 (Medium) -> Normalized 25-50
* Raw 31-50 (Complex) -> Normalized 50-75
* Raw 51+ (Very Complex) -> Normalized 75-100 (asymptotic)
*/
normalizeComplexity(rawComplexity) {
if (rawComplexity <= 15) {
// Linear: 0-15 -> 0-25
return Math.round((rawComplexity / 15) * 25)
}
else if (rawComplexity <= 30) {
// Linear: 16-30 -> 25-50
return Math.round(25 + ((rawComplexity - 15) / 15) * 25)
}
else if (rawComplexity <= 50) {
// Linear: 31-50 -> 50-75
return Math.round(50 + ((rawComplexity - 30) / 20) * 25)
}
else {
// Asymptotic: 51+ -> 75-100
// Formula ensures score approaches but never exceeds 100
return Math.round(75 + 25 * (1 - 1 / (1 + (rawComplexity - 50) / 100)))
}
}
/**
* Count how many times a component is referenced in the codebase
* Scans TypeScript sources for import statements referencing the component
*/
countUsageReferences(filePath, absolutePath) {
try {
const resolvedComponentPath = absolutePath ?? path.resolve(process.cwd(), filePath)
const fileName = path.basename(resolvedComponentPath, path.extname(resolvedComponentPath))
let searchName = fileName
if (fileName === 'index') {
const parentDir = path.dirname(resolvedComponentPath)
searchName = path.basename(parentDir)
}
if (!searchName)
return 0
const searchRoots = this.collectSearchRoots(resolvedComponentPath)
if (searchRoots.length === 0)
return 0
const escapedName = ComponentAnalyzer.escapeRegExp(searchName)
const patterns = [
new RegExp(`from\\s+['\"][^'\"]*(?:/|^)${escapedName}(?:['\"/]|$)`),
new RegExp(`import\\s*\\(\\s*['\"][^'\"]*(?:/|^)${escapedName}(?:['\"/]|$)`),
new RegExp(`export\\s+(?:\\*|{[^}]*})\\s*from\\s+['\"][^'\"]*(?:/|^)${escapedName}(?:['\"/]|$)`),
new RegExp(`require\\(\\s*['\"][^'\"]*(?:/|^)${escapedName}(?:['\"/]|$)`),
]
const visited = new Set()
let usageCount = 0
const stack = [...searchRoots]
while (stack.length > 0) {
const currentDir = stack.pop()
if (!currentDir || visited.has(currentDir))
continue
visited.add(currentDir)
const entries = fs.readdirSync(currentDir, { withFileTypes: true })
entries.forEach((entry) => {
const entryPath = path.join(currentDir, entry.name)
if (entry.isDirectory()) {
if (this.shouldSkipDir(entry.name))
return
stack.push(entryPath)
return
}
if (!this.shouldInspectFile(entry.name))
return
const normalizedEntryPath = path.resolve(entryPath)
if (normalizedEntryPath === path.resolve(resolvedComponentPath))
return
const source = fs.readFileSync(entryPath, 'utf-8')
if (!source.includes(searchName))
return
if (patterns.some((pattern) => {
pattern.lastIndex = 0
return pattern.test(source)
})) {
usageCount += 1
}
})
}
return usageCount
}
catch {
// If command fails, return 0
return 0
}
}
collectSearchRoots(resolvedComponentPath) {
const roots = new Set()
let currentDir = path.dirname(resolvedComponentPath)
const workspaceRoot = process.cwd()
while (currentDir && currentDir !== path.dirname(currentDir)) {
if (path.basename(currentDir) === 'app') {
roots.add(currentDir)
break
}
if (currentDir === workspaceRoot)
break
currentDir = path.dirname(currentDir)
}
const fallbackRoots = [
path.join(workspaceRoot, 'app'),
path.join(workspaceRoot, 'web', 'app'),
path.join(workspaceRoot, 'src'),
]
fallbackRoots.forEach((root) => {
if (fs.existsSync(root) && fs.statSync(root).isDirectory())
roots.add(root)
})
return Array.from(roots)
}
shouldSkipDir(dirName) {
const normalized = dirName.toLowerCase()
return [
'node_modules',
'.git',
'.next',
'dist',
'out',
'coverage',
'build',
'__tests__',
'__mocks__',
].includes(normalized)
}
shouldInspectFile(fileName) {
const normalized = fileName.toLowerCase()
if (!(/\.(ts|tsx)$/i.test(fileName)))
return false
if (normalized.endsWith('.d.ts'))
return false
if (/\.(spec|test)\.(ts|tsx)$/.test(normalized))
return false
if (normalized.endsWith('.stories.tsx'))
return false
return true
}
static escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
/**
* Calculate test priority based on cognitive complexity and usage
*
* Priority Score = 0.7 * Complexity + 0.3 * Usage Score (all normalized to 0-100)
* - Complexity Score: 0-100 (normalized from SonarJS)
* - Usage Score: 0-100 (based on reference count)
*
* Priority Levels (0-100):
* - 0-25: 🟢 LOW
* - 26-50: 🟡 MEDIUM
* - 51-75: 🟠 HIGH
* - 76-100: 🔴 CRITICAL
*/
calculateTestPriority(complexity, usageCount) {
const complexityScore = complexity
// Normalize usage score to 0-100
let usageScore
if (usageCount === 0)
usageScore = 0
else if (usageCount <= 5)
usageScore = 20
else if (usageCount <= 20)
usageScore = 40
else if (usageCount <= 50)
usageScore = 70
else
usageScore = 100
// Weighted average: complexity (70%) + usage (30%)
const totalScore = Math.round(0.7 * complexityScore + 0.3 * usageScore)
return {
score: totalScore,
level: this.getPriorityLevel(totalScore),
usageScore,
complexityScore,
}
}
/**
* Get priority level based on score (0-100 scale)
*/
getPriorityLevel(score) {
if (score > 75)
return '🔴 CRITICAL'
if (score > 50)
return '🟠 HIGH'
if (score > 25)
return '🟡 MEDIUM'
return '🟢 LOW'
}
}
import {
ComponentAnalyzer,
extractCopyContent,
getComplexityLevel,
listAnalyzableFiles,
resolveDirectoryEntry,
} from './component-analyzer.js'
// ============================================================================
// Prompt Builder for AI Assistants
@ -394,8 +31,8 @@ class TestPromptBuilder {
📊 Component Analysis:
Type: ${analysis.type}
Total Complexity: ${analysis.complexity}/100 ${this.getComplexityLevel(analysis.complexity)}
Max Func Complexity: ${analysis.maxComplexity}/100 ${this.getComplexityLevel(analysis.maxComplexity)}
Total Complexity: ${analysis.complexity}/100 ${getComplexityLevel(analysis.complexity)}
Max Func Complexity: ${analysis.maxComplexity}/100 ${getComplexityLevel(analysis.maxComplexity)}
Lines: ${analysis.lineCount}
Usage: ${analysis.usageCount} reference${analysis.usageCount !== 1 ? 's' : ''}
Test Priority: ${analysis.priority.score} ${analysis.priority.level}
@ -444,17 +81,6 @@ Create the test file at: ${testPath}
`
}
getComplexityLevel(score) {
// Normalized complexity thresholds (0-100 scale)
if (score <= 25)
return '🟢 Simple'
if (score <= 50)
return '🟡 Medium'
if (score <= 75)
return '🟠 Complex'
return '🔴 Very Complex'
}
buildFocusPoints(analysis) {
const points = []
@ -730,94 +356,10 @@ Output format:
}
}
function extractCopyContent(prompt) {
const marker = '📋 PROMPT FOR AI ASSISTANT'
const markerIndex = prompt.indexOf(marker)
if (markerIndex === -1)
return ''
const section = prompt.slice(markerIndex)
const lines = section.split('\n')
const firstDivider = lines.findIndex(line => line.includes('━━━━━━━━'))
if (firstDivider === -1)
return ''
const startIdx = firstDivider + 1
let endIdx = lines.length
for (let i = startIdx; i < lines.length; i++) {
if (lines[i].includes('━━━━━━━━')) {
endIdx = i
break
}
}
if (startIdx >= endIdx)
return ''
return lines.slice(startIdx, endIdx).join('\n').trim()
}
// ============================================================================
// Main Function
// ============================================================================
/**
* Resolve directory to entry file
* Priority: index files > common entry files (node.tsx, panel.tsx, etc.)
*/
function resolveDirectoryEntry(absolutePath, componentPath) {
// Entry files in priority order: index files first, then common entry files
const entryFiles = [
'index.tsx',
'index.ts', // Priority 1: index files
'node.tsx',
'panel.tsx',
'component.tsx',
'main.tsx',
'container.tsx', // Priority 2: common entry files
]
for (const entryFile of entryFiles) {
const entryPath = path.join(absolutePath, entryFile)
if (fs.existsSync(entryPath)) {
return {
absolutePath: entryPath,
componentPath: path.join(componentPath, entryFile),
}
}
}
return null
}
/**
* List analyzable files in directory (for user guidance)
*/
function listAnalyzableFiles(dirPath) {
try {
const entries = fs.readdirSync(dirPath, { withFileTypes: true })
return entries
.filter(entry => !entry.isDirectory() && /\.(tsx?|jsx?)$/.test(entry.name) && !entry.name.endsWith('.d.ts'))
.map(entry => entry.name)
.sort((a, b) => {
// Prioritize common entry files
const priority = ['index.tsx', 'index.ts', 'node.tsx', 'panel.tsx', 'component.tsx', 'main.tsx', 'container.tsx']
const aIdx = priority.indexOf(a)
const bIdx = priority.indexOf(b)
if (aIdx !== -1 && bIdx !== -1)
return aIdx - bIdx
if (aIdx !== -1)
return -1
if (bIdx !== -1)
return 1
return a.localeCompare(b)
})
}
catch {
return []
}
}
function showHelp() {
console.log(`
📋 Component Analyzer - Generate test prompts for AI assistants

View File

@ -0,0 +1,484 @@
/**
* Component Analyzer - Shared module for analyzing React component complexity
*
* This module is used by:
* - analyze-component.js (for test generation)
* - refactor-component.js (for refactoring suggestions)
*/
import fs from 'node:fs'
import path from 'node:path'
import tsParser from '@typescript-eslint/parser'
import { Linter } from 'eslint'
import sonarPlugin from 'eslint-plugin-sonarjs'
// ============================================================================
// Component Analyzer
// ============================================================================
export class ComponentAnalyzer {
analyze(code, filePath, absolutePath) {
const resolvedPath = absolutePath ?? path.resolve(process.cwd(), filePath)
const fileName = path.basename(filePath, path.extname(filePath))
const lineCount = code.split('\n').length
// Calculate complexity metrics
const { total: rawComplexity, max: rawMaxComplexity } = this.calculateCognitiveComplexity(code)
const complexity = this.normalizeComplexity(rawComplexity)
const maxComplexity = this.normalizeComplexity(rawMaxComplexity)
// Count usage references (may take a few seconds)
const usageCount = this.countUsageReferences(filePath, resolvedPath)
// Calculate test priority
const priority = this.calculateTestPriority(complexity, usageCount)
return {
name: fileName.charAt(0).toUpperCase() + fileName.slice(1),
path: filePath,
type: this.detectType(filePath, code),
hasProps: code.includes('Props') || code.includes('interface'),
hasState: code.includes('useState') || code.includes('useReducer'),
hasEffects: code.includes('useEffect'),
hasCallbacks: code.includes('useCallback'),
hasMemo: code.includes('useMemo'),
hasEvents: /on[A-Z]\w+/.test(code),
hasRouter: code.includes('useRouter') || code.includes('usePathname'),
hasAPI: code.includes('service/') || code.includes('fetch(') || code.includes('useSWR'),
hasForwardRef: code.includes('forwardRef'),
hasComponentMemo: /React\.memo|memo\(/.test(code),
hasSuspense: code.includes('Suspense') || /\blazy\(/.test(code),
hasPortal: code.includes('createPortal'),
hasImperativeHandle: code.includes('useImperativeHandle'),
hasSWR: code.includes('useSWR'),
hasReactQuery: code.includes('useQuery') || code.includes('useMutation'),
hasAhooks: code.includes('from \'ahooks\''),
complexity,
maxComplexity,
rawComplexity,
rawMaxComplexity,
lineCount,
usageCount,
priority,
}
}
detectType(filePath, code) {
const normalizedPath = filePath.replace(/\\/g, '/')
if (normalizedPath.includes('/hooks/'))
return 'hook'
if (normalizedPath.includes('/utils/'))
return 'util'
if (/\/page\.(t|j)sx?$/.test(normalizedPath))
return 'page'
if (/\/layout\.(t|j)sx?$/.test(normalizedPath))
return 'layout'
if (/\/providers?\//.test(normalizedPath))
return 'provider'
// Dify-specific types
if (normalizedPath.includes('/components/base/'))
return 'base-component'
if (normalizedPath.includes('/context/'))
return 'context'
if (normalizedPath.includes('/store/'))
return 'store'
if (normalizedPath.includes('/service/'))
return 'service'
if (/use[A-Z]\w+/.test(code))
return 'component'
return 'component'
}
/**
* Calculate Cognitive Complexity using SonarJS ESLint plugin
* Reference: https://www.sonarsource.com/blog/5-clean-code-tips-for-reducing-cognitive-complexity/
*
* Returns raw (unnormalized) complexity values:
* - total: sum of all functions' complexity in the file
* - max: highest single function complexity in the file
*
* Raw Score Thresholds (per function):
* 0-15: Simple | 16-30: Medium | 31-50: Complex | 51+: Very Complex
*
* @returns {{ total: number, max: number }} raw total and max complexity
*/
calculateCognitiveComplexity(code) {
const linter = new Linter()
const baseConfig = {
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
ecmaFeatures: { jsx: true },
},
},
plugins: { sonarjs: sonarPlugin },
}
try {
// Get total complexity using 'metric' option (more stable)
const totalConfig = {
...baseConfig,
rules: { 'sonarjs/cognitive-complexity': ['error', 0, 'metric'] },
}
const totalMessages = linter.verify(code, totalConfig)
const totalMsg = totalMessages.find(
msg => msg.ruleId === 'sonarjs/cognitive-complexity'
&& msg.messageId === 'fileComplexity',
)
const total = totalMsg ? Number.parseInt(totalMsg.message, 10) : 0
// Get max function complexity by analyzing each function
const maxConfig = {
...baseConfig,
rules: { 'sonarjs/cognitive-complexity': ['error', 0] },
}
const maxMessages = linter.verify(code, maxConfig)
let max = 0
const complexityPattern = /reduce its Cognitive Complexity from (\d+)/
maxMessages.forEach((msg) => {
if (msg.ruleId === 'sonarjs/cognitive-complexity') {
const match = msg.message.match(complexityPattern)
if (match && match[1])
max = Math.max(max, Number.parseInt(match[1], 10))
}
})
return { total, max }
}
catch {
return { total: 0, max: 0 }
}
}
/**
* Normalize cognitive complexity to 0-100 scale
*
* Mapping (aligned with SonarJS thresholds):
* Raw 0-15 (Simple) -> Normalized 0-25
* Raw 16-30 (Medium) -> Normalized 25-50
* Raw 31-50 (Complex) -> Normalized 50-75
* Raw 51+ (Very Complex) -> Normalized 75-100 (asymptotic)
*/
normalizeComplexity(rawComplexity) {
if (rawComplexity <= 15) {
// Linear: 0-15 -> 0-25
return Math.round((rawComplexity / 15) * 25)
}
else if (rawComplexity <= 30) {
// Linear: 16-30 -> 25-50
return Math.round(25 + ((rawComplexity - 15) / 15) * 25)
}
else if (rawComplexity <= 50) {
// Linear: 31-50 -> 50-75
return Math.round(50 + ((rawComplexity - 30) / 20) * 25)
}
else {
// Asymptotic: 51+ -> 75-100
// Formula ensures score approaches but never exceeds 100
return Math.round(75 + 25 * (1 - 1 / (1 + (rawComplexity - 50) / 100)))
}
}
/**
* Count how many times a component is referenced in the codebase
* Scans TypeScript sources for import statements referencing the component
*/
countUsageReferences(filePath, absolutePath) {
try {
const resolvedComponentPath = absolutePath ?? path.resolve(process.cwd(), filePath)
const fileName = path.basename(resolvedComponentPath, path.extname(resolvedComponentPath))
let searchName = fileName
if (fileName === 'index') {
const parentDir = path.dirname(resolvedComponentPath)
searchName = path.basename(parentDir)
}
if (!searchName)
return 0
const searchRoots = this.collectSearchRoots(resolvedComponentPath)
if (searchRoots.length === 0)
return 0
const escapedName = ComponentAnalyzer.escapeRegExp(searchName)
const patterns = [
new RegExp(`from\\s+['\"][^'\"]*(?:/|^)${escapedName}(?:['\"/]|$)`),
new RegExp(`import\\s*\\(\\s*['\"][^'\"]*(?:/|^)${escapedName}(?:['\"/]|$)`),
new RegExp(`export\\s+(?:\\*|{[^}]*})\\s*from\\s+['\"][^'\"]*(?:/|^)${escapedName}(?:['\"/]|$)`),
new RegExp(`require\\(\\s*['\"][^'\"]*(?:/|^)${escapedName}(?:['\"/]|$)`),
]
const visited = new Set()
let usageCount = 0
const stack = [...searchRoots]
while (stack.length > 0) {
const currentDir = stack.pop()
if (!currentDir || visited.has(currentDir))
continue
visited.add(currentDir)
const entries = fs.readdirSync(currentDir, { withFileTypes: true })
entries.forEach((entry) => {
const entryPath = path.join(currentDir, entry.name)
if (entry.isDirectory()) {
if (this.shouldSkipDir(entry.name))
return
stack.push(entryPath)
return
}
if (!this.shouldInspectFile(entry.name))
return
const normalizedEntryPath = path.resolve(entryPath)
if (normalizedEntryPath === path.resolve(resolvedComponentPath))
return
const source = fs.readFileSync(entryPath, 'utf-8')
if (!source.includes(searchName))
return
if (patterns.some((pattern) => {
pattern.lastIndex = 0
return pattern.test(source)
})) {
usageCount += 1
}
})
}
return usageCount
}
catch {
// If command fails, return 0
return 0
}
}
collectSearchRoots(resolvedComponentPath) {
const roots = new Set()
let currentDir = path.dirname(resolvedComponentPath)
const workspaceRoot = process.cwd()
while (currentDir && currentDir !== path.dirname(currentDir)) {
if (path.basename(currentDir) === 'app') {
roots.add(currentDir)
break
}
if (currentDir === workspaceRoot)
break
currentDir = path.dirname(currentDir)
}
const fallbackRoots = [
path.join(workspaceRoot, 'app'),
path.join(workspaceRoot, 'web', 'app'),
path.join(workspaceRoot, 'src'),
]
fallbackRoots.forEach((root) => {
if (fs.existsSync(root) && fs.statSync(root).isDirectory())
roots.add(root)
})
return Array.from(roots)
}
shouldSkipDir(dirName) {
const normalized = dirName.toLowerCase()
return [
'node_modules',
'.git',
'.next',
'dist',
'out',
'coverage',
'build',
'__tests__',
'__mocks__',
].includes(normalized)
}
shouldInspectFile(fileName) {
const normalized = fileName.toLowerCase()
if (!(/\.(ts|tsx)$/i.test(fileName)))
return false
if (normalized.endsWith('.d.ts'))
return false
if (/\.(spec|test)\.(ts|tsx)$/.test(normalized))
return false
if (normalized.endsWith('.stories.tsx'))
return false
return true
}
static escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
/**
* Calculate test priority based on cognitive complexity and usage
*
* Priority Score = 0.7 * Complexity + 0.3 * Usage Score (all normalized to 0-100)
* - Complexity Score: 0-100 (normalized from SonarJS)
* - Usage Score: 0-100 (based on reference count)
*
* Priority Levels (0-100):
* - 0-25: 🟢 LOW
* - 26-50: 🟡 MEDIUM
* - 51-75: 🟠 HIGH
* - 76-100: 🔴 CRITICAL
*/
calculateTestPriority(complexity, usageCount) {
const complexityScore = complexity
// Normalize usage score to 0-100
let usageScore
if (usageCount === 0)
usageScore = 0
else if (usageCount <= 5)
usageScore = 20
else if (usageCount <= 20)
usageScore = 40
else if (usageCount <= 50)
usageScore = 70
else
usageScore = 100
// Weighted average: complexity (70%) + usage (30%)
const totalScore = Math.round(0.7 * complexityScore + 0.3 * usageScore)
return {
score: totalScore,
level: this.getPriorityLevel(totalScore),
usageScore,
complexityScore,
}
}
/**
* Get priority level based on score (0-100 scale)
*/
getPriorityLevel(score) {
if (score > 75)
return '🔴 CRITICAL'
if (score > 50)
return '🟠 HIGH'
if (score > 25)
return '🟡 MEDIUM'
return '🟢 LOW'
}
}
// ============================================================================
// Helper Functions
// ============================================================================
/**
* Resolve directory to entry file
* Priority: index files > common entry files (node.tsx, panel.tsx, etc.)
*/
export function resolveDirectoryEntry(absolutePath, componentPath) {
// Entry files in priority order: index files first, then common entry files
const entryFiles = [
'index.tsx',
'index.ts', // Priority 1: index files
'node.tsx',
'panel.tsx',
'component.tsx',
'main.tsx',
'container.tsx', // Priority 2: common entry files
]
for (const entryFile of entryFiles) {
const entryPath = path.join(absolutePath, entryFile)
if (fs.existsSync(entryPath)) {
return {
absolutePath: entryPath,
componentPath: path.join(componentPath, entryFile),
}
}
}
return null
}
/**
* List analyzable files in directory (for user guidance)
*/
export function listAnalyzableFiles(dirPath) {
try {
const entries = fs.readdirSync(dirPath, { withFileTypes: true })
return entries
.filter(entry => !entry.isDirectory() && /\.(tsx?|jsx?)$/.test(entry.name) && !entry.name.endsWith('.d.ts'))
.map(entry => entry.name)
.sort((a, b) => {
// Prioritize common entry files
const priority = ['index.tsx', 'index.ts', 'node.tsx', 'panel.tsx', 'component.tsx', 'main.tsx', 'container.tsx']
const aIdx = priority.indexOf(a)
const bIdx = priority.indexOf(b)
if (aIdx !== -1 && bIdx !== -1)
return aIdx - bIdx
if (aIdx !== -1)
return -1
if (bIdx !== -1)
return 1
return a.localeCompare(b)
})
}
catch {
return []
}
}
/**
* Extract copy content from prompt (for clipboard)
*/
export function extractCopyContent(prompt) {
const marker = '📋 PROMPT FOR AI ASSISTANT (COPY THIS TO YOUR AI ASSISTANT):'
const markerIndex = prompt.indexOf(marker)
if (markerIndex === -1)
return ''
const section = prompt.slice(markerIndex)
const lines = section.split('\n')
const firstDivider = lines.findIndex(line => line.includes('━━━━━━━━'))
if (firstDivider === -1)
return ''
const startIdx = firstDivider + 1
let endIdx = lines.length
for (let i = startIdx; i < lines.length; i++) {
if (lines[i].includes('━━━━━━━━')) {
endIdx = i
break
}
}
if (startIdx >= endIdx)
return ''
return lines.slice(startIdx, endIdx).join('\n').trim()
}
/**
* Get complexity level label
*/
export function getComplexityLevel(score) {
if (score <= 25)
return '🟢 Simple'
if (score <= 50)
return '🟡 Medium'
if (score <= 75)
return '🟠 Complex'
return '🔴 Very Complex'
}

View File

@ -0,0 +1,420 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import {
ComponentAnalyzer,
extractCopyContent,
getComplexityLevel,
listAnalyzableFiles,
resolveDirectoryEntry,
} from './component-analyzer.js'
// ============================================================================
// Extended Analyzer for Refactoring
// ============================================================================
class RefactorAnalyzer extends ComponentAnalyzer {
analyze(code, filePath, absolutePath) {
// Get base analysis from parent class
const baseAnalysis = super.analyze(code, filePath, absolutePath)
// Add refactoring-specific metrics
// Note: These counts use regex matching which may include import statements.
// For most components this results in +1 over actual usage, which is acceptable
// for heuristic analysis. For precise AST-based counting, consider using
// @typescript-eslint/parser to traverse the AST.
const stateCount = (code.match(/useState\s*[(<]/g) || []).length
const effectCount = (code.match(/useEffect\s*\(/g) || []).length
const callbackCount = (code.match(/useCallback\s*\(/g) || []).length
const memoCount = (code.match(/useMemo\s*\(/g) || []).length
const conditionalBlocks = this.countConditionalBlocks(code)
const nestedTernaries = this.countNestedTernaries(code)
const hasContext = code.includes('useContext') || code.includes('createContext')
const hasReducer = code.includes('useReducer')
const hasModals = this.countModals(code)
return {
...baseAnalysis,
stateCount,
effectCount,
callbackCount,
memoCount,
conditionalBlocks,
nestedTernaries,
hasContext,
hasReducer,
hasModals,
}
}
countModals(code) {
const modalPatterns = [
/Modal/g,
/Dialog/g,
/Drawer/g,
/Confirm/g,
/showModal|setShowModal|isShown|isShowing/g,
]
let count = 0
modalPatterns.forEach((pattern) => {
const matches = code.match(pattern)
if (matches)
count += matches.length
})
return Math.floor(count / 3) // Rough estimate of actual modals
}
countConditionalBlocks(code) {
const ifBlocks = (code.match(/\bif\s*\(/g) || []).length
const ternaries = (code.match(/\?.*:/g) || []).length
const switchCases = (code.match(/\bswitch\s*\(/g) || []).length
return ifBlocks + ternaries + switchCases
}
countNestedTernaries(code) {
const nestedInTrueBranch = (code.match(/\?[^:?]*\?[^:]*:/g) || []).length
const nestedInFalseBranch = (code.match(/\?[^:?]*:[^?]*\?[^:]*:/g) || []).length
return nestedInTrueBranch + nestedInFalseBranch
}
}
// ============================================================================
// Refactor Prompt Builder
// ============================================================================
class RefactorPromptBuilder {
build(analysis) {
const refactorActions = this.identifyRefactorActions(analysis)
return `
🔧 REFACTOR DIFY COMPONENT
📍 Component: ${analysis.name}
📂 Path: ${analysis.path}
📊 Complexity Analysis:
Total Complexity: ${analysis.complexity}/100 ${getComplexityLevel(analysis.complexity)}
Max Func Complexity: ${analysis.maxComplexity}/100 ${getComplexityLevel(analysis.maxComplexity)}
Lines: ${analysis.lineCount} ${analysis.lineCount > 300 ? '⚠️ TOO LARGE' : ''}
Usage: ${analysis.usageCount} reference${analysis.usageCount !== 1 ? 's' : ''}
📈 Code Metrics:
useState calls: ${analysis.stateCount}
useEffect calls: ${analysis.effectCount}
useCallback calls: ${analysis.callbackCount}
useMemo calls: ${analysis.memoCount}
Conditional blocks: ${analysis.conditionalBlocks}
Nested ternaries: ${analysis.nestedTernaries}
Modal components: ${analysis.hasModals}
🔍 Features Detected:
${analysis.hasState ? '✓' : '✗'} Local state (useState/useReducer)
${analysis.hasEffects ? '✓' : '✗'} Side effects (useEffect)
${analysis.hasCallbacks ? '✓' : '✗'} Callbacks (useCallback)
${analysis.hasMemo ? '✓' : '✗'} Memoization (useMemo)
${analysis.hasContext ? '✓' : '✗'} Context (useContext/createContext)
${analysis.hasEvents ? '✓' : '✗'} Event handlers
${analysis.hasRouter ? '✓' : '✗'} Next.js routing
${analysis.hasAPI ? '✓' : '✗'} API calls
${analysis.hasReactQuery ? '✓' : '✗'} React Query
${analysis.hasSWR ? '✓' : '✗'} SWR (should migrate to React Query)
${analysis.hasAhooks ? '✓' : '✗'} ahooks
🎯 RECOMMENDED REFACTORING ACTIONS:
${refactorActions.map((action, i) => `${i + 1}. ${action}`).join('\n')}
📋 PROMPT FOR AI ASSISTANT (COPY THIS TO YOUR AI ASSISTANT):
Please refactor the component at @${analysis.path}
Component metrics:
- Complexity: ${analysis.complexity}/100 (target: < 50)
- Lines: ${analysis.lineCount} (target: < 300)
- useState: ${analysis.stateCount}, useEffect: ${analysis.effectCount}
Refactoring tasks:
${refactorActions.map(action => `- ${action}`).join('\n')}
Requirements:
${this.buildRequirements(analysis)}
Follow Dify project conventions:
- Place extracted hooks in \`hooks/\` subdirectory or as \`use-<feature>.ts\`
- Use React Query (\`@tanstack/react-query\`) for data fetching, not SWR
- Follow existing patterns in \`web/service/use-*.ts\` for API hooks
- Keep each new file under 300 lines
- Maintain TypeScript strict typing
After refactoring, verify:
- \`pnpm lint:fix\` passes
- \`pnpm type-check:tsgo\` passes
- Re-run \`pnpm refactor-component ${analysis.path}\` to confirm complexity < 50
`
}
identifyRefactorActions(analysis) {
const actions = []
// Priority 1: Extract hooks for complex state management
if (analysis.stateCount >= 3 || (analysis.stateCount >= 2 && analysis.effectCount >= 2)) {
actions.push(`🪝 EXTRACT CUSTOM HOOK: ${analysis.stateCount} useState + ${analysis.effectCount} useEffect detected. Extract related state and effects into a custom hook (e.g., \`use${analysis.name}State.ts\`)`)
}
// Priority 2: Extract API/data logic
if (analysis.hasAPI && (analysis.hasEffects || analysis.hasSWR)) {
if (analysis.hasSWR) {
actions.push('🔄 MIGRATE SWR TO REACT QUERY: Replace useSWR with useQuery from @tanstack/react-query')
}
actions.push('🌐 EXTRACT DATA HOOK: Move API calls and data fetching logic into a dedicated hook using React Query')
}
// Priority 3: Split large components
if (analysis.lineCount > 300) {
actions.push(`📦 SPLIT COMPONENT: ${analysis.lineCount} lines exceeds limit. Extract UI sections into sub-components`)
}
// Priority 4: Extract modal management
if (analysis.hasModals >= 2) {
actions.push(`🔲 EXTRACT MODAL MANAGEMENT: ${analysis.hasModals} modal-related patterns detected. Create a useModalState hook or separate modal components`)
}
// Priority 5: Simplify conditionals
if (analysis.conditionalBlocks > 10 || analysis.nestedTernaries >= 2) {
actions.push('🔀 SIMPLIFY CONDITIONALS: Use lookup tables, early returns, or extract complex conditions into named functions')
}
// Priority 6: Extract callbacks
if (analysis.callbackCount >= 4) {
actions.push(`⚡ CONSOLIDATE CALLBACKS: ${analysis.callbackCount} useCallback calls. Consider extracting related callbacks into a custom hook`)
}
// Priority 7: Context provider extraction
if (analysis.hasContext && analysis.complexity > 50) {
actions.push('🎯 EXTRACT CONTEXT LOGIC: Move context provider logic into separate files or split into domain-specific contexts')
}
// Priority 8: Memoization review
if (analysis.memoCount >= 3 && analysis.complexity > 50) {
actions.push(`📝 REVIEW MEMOIZATION: ${analysis.memoCount} useMemo calls. Extract complex computations into utility functions or hooks`)
}
// If no specific issues, provide general guidance
if (actions.length === 0) {
if (analysis.complexity > 50) {
actions.push('🔍 ANALYZE FUNCTIONS: Review individual functions for complexity and extract helper functions')
}
else {
actions.push('✅ Component complexity is acceptable. Consider minor improvements for maintainability')
}
}
return actions
}
buildRequirements(analysis) {
const requirements = []
if (analysis.stateCount >= 3) {
requirements.push('- Group related useState calls into a single custom hook')
requirements.push('- Move associated useEffect calls with the state they depend on')
}
if (analysis.hasAPI) {
requirements.push('- Create data fetching hook following web/service/use-*.ts patterns')
requirements.push('- Use useQuery with proper queryKey and enabled options')
requirements.push('- Export invalidation hook (useInvalidXxx) for cache management')
}
if (analysis.lineCount > 300) {
requirements.push('- Extract logical UI sections into separate components')
requirements.push('- Keep parent component focused on orchestration')
requirements.push('- Pass minimal props to child components')
}
if (analysis.hasModals >= 2) {
requirements.push('- Create unified modal state management')
requirements.push('- Consider extracting modals to separate file')
}
if (analysis.conditionalBlocks > 10) {
requirements.push('- Replace switch statements with lookup tables')
requirements.push('- Use early returns to reduce nesting')
requirements.push('- Extract complex boolean logic to named functions')
}
if (requirements.length === 0) {
requirements.push('- Maintain existing code structure')
requirements.push('- Focus on readability improvements')
}
return requirements.join('\n')
}
}
// ============================================================================
// Main Function
// ============================================================================
function showHelp() {
console.log(`
🔧 Component Refactor Tool - Generate refactoring prompts for AI assistants
Usage:
node refactor-component.js <component-path> [options]
pnpm refactor-component <component-path> [options]
Options:
--help Show this help message
--json Output analysis result as JSON (for programmatic use)
Examples:
# Analyze and generate refactoring prompt
pnpm refactor-component app/components/app/configuration/index.tsx
# Output as JSON
pnpm refactor-component app/components/tools/mcp/modal.tsx --json
Complexity Thresholds:
🟢 0-25: Simple (no refactoring needed)
🟡 26-50: Medium (consider minor refactoring)
🟠 51-75: Complex (should refactor)
🔴 76-100: Very Complex (must refactor)
For complete refactoring guidelines, see:
.claude/skills/component-refactoring/SKILL.md
`)
}
function main() {
const rawArgs = process.argv.slice(2)
let isJsonMode = false
const args = []
rawArgs.forEach((arg) => {
if (arg === '--json') {
isJsonMode = true
return
}
if (arg === '--help' || arg === '-h') {
showHelp()
process.exit(0)
}
args.push(arg)
})
if (args.length === 0) {
showHelp()
process.exit(1)
}
let componentPath = args[0]
let absolutePath = path.resolve(process.cwd(), componentPath)
if (!fs.existsSync(absolutePath)) {
console.error(`❌ Error: Path not found: ${componentPath}`)
process.exit(1)
}
if (fs.statSync(absolutePath).isDirectory()) {
const resolvedFile = resolveDirectoryEntry(absolutePath, componentPath)
if (resolvedFile) {
absolutePath = resolvedFile.absolutePath
componentPath = resolvedFile.componentPath
}
else {
const availableFiles = listAnalyzableFiles(absolutePath)
console.error(`❌ Error: Directory does not contain a recognizable entry file: ${componentPath}`)
if (availableFiles.length > 0) {
console.error(`\n Available files to analyze:`)
availableFiles.forEach(f => console.error(` - ${path.join(componentPath, f)}`))
console.error(`\n Please specify the exact file path, e.g.:`)
console.error(` pnpm refactor-component ${path.join(componentPath, availableFiles[0])}`)
}
process.exit(1)
}
}
const sourceCode = fs.readFileSync(absolutePath, 'utf-8')
const analyzer = new RefactorAnalyzer()
const analysis = analyzer.analyze(sourceCode, componentPath, absolutePath)
// JSON output mode
if (isJsonMode) {
console.log(JSON.stringify(analysis, null, 2))
return
}
// Check if refactoring is needed
if (analysis.complexity <= 25 && analysis.lineCount <= 200) {
console.log(`
COMPONENT IS WELL-STRUCTURED
📍 Component: ${analysis.name}
📂 Path: ${analysis.path}
📊 Metrics:
Complexity: ${analysis.complexity}/100 🟢 Simple
Lines: ${analysis.lineCount} Within limits
This component has good structure. No immediate refactoring needed.
You can proceed with testing using: pnpm analyze-component ${componentPath}
`)
return
}
// Build refactoring prompt
const builder = new RefactorPromptBuilder()
const prompt = builder.build(analysis)
console.log(prompt)
// Copy to clipboard (macOS)
try {
const checkPbcopy = spawnSync('which', ['pbcopy'], { stdio: 'pipe' })
if (checkPbcopy.status !== 0)
return
const copyContent = extractCopyContent(prompt)
if (!copyContent)
return
const result = spawnSync('pbcopy', [], {
input: copyContent,
encoding: 'utf-8',
})
if (result.status === 0) {
console.log('\n📋 Refactoring prompt copied to clipboard!')
console.log(' Paste it in your AI assistant:')
console.log(' - Cursor: Cmd+L (Chat) or Cmd+I (Composer)')
console.log(' - GitHub Copilot Chat: Cmd+I')
console.log(' - Or any other AI coding tool\n')
}
}
catch {
// pbcopy failed, but don't break the script
}
}
// ============================================================================
// Run
// ============================================================================
main()

View File

@ -371,6 +371,8 @@ export const useTriggerPluginDynamicOptions = (payload: {
},
enabled: enabled && !!payload.plugin_id && !!payload.provider && !!payload.action && !!payload.parameter && !!payload.credential_id,
retry: 0,
staleTime: 0,
gcTime: 0,
})
}

View File

@ -33,7 +33,7 @@ pnpm test path/to/file.spec.tsx
- **Global setup**: `vitest.setup.ts` already imports `@testing-library/jest-dom`, runs `cleanup()` after every test, and defines shared mocks (for example `react-i18next`, `next/image`). Add any environment-level mocks (for example `ResizeObserver`, `matchMedia`, `IntersectionObserver`, `TextEncoder`, `crypto`) here so they are shared consistently.
- **Reusable mocks**: Place shared mock factories inside `web/__mocks__/` and use `vi.mock('module-name')` to point to them rather than redefining mocks in every spec.
- **Mocking behavior**: Modules are not mocked automatically. Use `vi.mock(...)` in tests, or place global mocks in `vitest.setup.ts`.
- **Script utilities**: `web/testing/analyze-component.js` analyzes component complexity and generates test prompts for AI assistants. Commands:
- **Script utilities**: `web/scripts/analyze-component.js` analyzes component complexity and generates test prompts for AI assistants. Commands:
- `pnpm analyze-component <path>` - Analyze and generate test prompt
- `pnpm analyze-component <path> --json` - Output analysis as JSON
- `pnpm analyze-component <path> --review` - Generate test review prompt

View File

@ -131,4 +131,5 @@ export enum DatasetAttr {
NEXT_PUBLIC_ZENDESK_FIELD_ID_EMAIL = 'next-public-zendesk-field-id-email',
NEXT_PUBLIC_ZENDESK_FIELD_ID_WORKSPACE_ID = 'next-public-zendesk-field-id-workspace-id',
NEXT_PUBLIC_ZENDESK_FIELD_ID_PLAN = 'next-public-zendesk-field-id-plan',
DATA_PUBLIC_BATCH_CONCURRENCY = 'data-public-batch-concurrency',
}