mirror of https://github.com/langgenius/dify.git
feat(refactoring): introduce comprehensive guidelines and tools for component refactoring in Dify (#30162)
Co-authored-by: CodingOnStar <hanxujiang@dify.ai> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
This commit is contained in:
parent
c3bb95d71d
commit
f2555b0bb1
|
|
@ -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.config.ts` - Vitest configuration
|
||||||
- `web/vitest.setup.ts` - Test environment setup
|
- `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.
|
- 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.
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,8 @@
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:coverage": "vitest run --coverage",
|
"test:coverage": "vitest run --coverage",
|
||||||
"test:watch": "vitest --watch",
|
"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",
|
"storybook": "storybook dev -p 6006",
|
||||||
"build-storybook": "storybook build",
|
"build-storybook": "storybook build",
|
||||||
"preinstall": "npx only-allow pnpm",
|
"preinstall": "npx only-allow pnpm",
|
||||||
|
|
|
||||||
|
|
@ -3,376 +3,13 @@
|
||||||
import { spawnSync } from 'node:child_process'
|
import { spawnSync } from 'node:child_process'
|
||||||
import fs from 'node:fs'
|
import fs from 'node:fs'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import tsParser from '@typescript-eslint/parser'
|
import {
|
||||||
import { Linter } from 'eslint'
|
ComponentAnalyzer,
|
||||||
import sonarPlugin from 'eslint-plugin-sonarjs'
|
extractCopyContent,
|
||||||
|
getComplexityLevel,
|
||||||
// ============================================================================
|
listAnalyzableFiles,
|
||||||
// Simple Analyzer
|
resolveDirectoryEntry,
|
||||||
// ============================================================================
|
} from './component-analyzer.js'
|
||||||
|
|
||||||
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'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Prompt Builder for AI Assistants
|
// Prompt Builder for AI Assistants
|
||||||
|
|
@ -394,8 +31,8 @@ class TestPromptBuilder {
|
||||||
📊 Component Analysis:
|
📊 Component Analysis:
|
||||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
Type: ${analysis.type}
|
Type: ${analysis.type}
|
||||||
Total Complexity: ${analysis.complexity}/100 ${this.getComplexityLevel(analysis.complexity)}
|
Total Complexity: ${analysis.complexity}/100 ${getComplexityLevel(analysis.complexity)}
|
||||||
Max Func Complexity: ${analysis.maxComplexity}/100 ${this.getComplexityLevel(analysis.maxComplexity)}
|
Max Func Complexity: ${analysis.maxComplexity}/100 ${getComplexityLevel(analysis.maxComplexity)}
|
||||||
Lines: ${analysis.lineCount}
|
Lines: ${analysis.lineCount}
|
||||||
Usage: ${analysis.usageCount} reference${analysis.usageCount !== 1 ? 's' : ''}
|
Usage: ${analysis.usageCount} reference${analysis.usageCount !== 1 ? 's' : ''}
|
||||||
Test Priority: ${analysis.priority.score} ${analysis.priority.level}
|
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) {
|
buildFocusPoints(analysis) {
|
||||||
const points = []
|
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
|
// 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() {
|
function showHelp() {
|
||||||
console.log(`
|
console.log(`
|
||||||
📋 Component Analyzer - Generate test prompts for AI assistants
|
📋 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()
|
||||||
|
|
@ -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.
|
- **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.
|
- **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`.
|
- **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>` - Analyze and generate test prompt
|
||||||
- `pnpm analyze-component <path> --json` - Output analysis as JSON
|
- `pnpm analyze-component <path> --json` - Output analysis as JSON
|
||||||
- `pnpm analyze-component <path> --review` - Generate test review prompt
|
- `pnpm analyze-component <path> --review` - Generate test review prompt
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue