mirror of https://github.com/langgenius/dify.git
Merge branch 'main' into fix/img_302_error
This commit is contained in:
commit
30e83fc02e
|
|
@ -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
|
||||
|
|
@ -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 |
|
||||
|
|
@ -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" />}
|
||||
/>
|
||||
```
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "dify-api"
|
||||
version = "1.11.1"
|
||||
version = "1.11.2"
|
||||
requires-python = ">=3.11,<3.13"
|
||||
|
||||
dependencies = [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,7 +68,6 @@ 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,
|
||||
|
|
@ -81,13 +80,15 @@ class WorkflowToolManageService:
|
|||
privacy_policy=privacy_policy,
|
||||
version=workflow.version,
|
||||
)
|
||||
session.add(workflow_tool_provider)
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:-}
|
||||
|
|
|
|||
|
|
@ -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:-}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
? {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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,31 +82,24 @@ 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')}>
|
||||
<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 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')}
|
||||
>
|
||||
<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 => (
|
||||
<MenuItem key={role}>
|
||||
<div className="flex cursor-pointer rounded-lg px-3 py-2 hover:bg-state-base-hover" onClick={() => handleUpdateMemberRole(role)}>
|
||||
<div key={role} 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" />
|
||||
|
|
@ -110,11 +110,9 @@ const Operation = ({
|
|||
<div className="system-xs-regular whitespace-nowrap text-text-tertiary">{t(`common.members.${toHump(role)}Tip` as any)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
))
|
||||
}
|
||||
</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" />
|
||||
|
|
@ -124,14 +122,10 @@ const Operation = ({
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
</Transition>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</Menu>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default Operation
|
||||
export default memo(Operation)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?.(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ export enum MetadataFilteringVariableType {
|
|||
export type MetadataFilteringCondition = {
|
||||
id: string
|
||||
name: string
|
||||
metadata_id?: string
|
||||
comparison_operator: ComparisonOperator
|
||||
value?: string | number
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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'
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue