feat: app deploy (#35670)

Co-authored-by: zhangx1n <zhangxin@dify.ai>
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Stephen Zhou 2026-06-17 17:28:43 +08:00 committed by GitHub
parent 0ea0647dd0
commit 48452aefbc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
230 changed files with 23796 additions and 2956 deletions

View File

@ -1,440 +0,0 @@
---
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
function Configuration() {
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
function Configuration() {
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**:
- This skill is for component decomposition, not query/mutation design.
- Do not introduce deprecated `useInvalid` / `useReset`.
- Do not add thin passthrough `useQuery` wrappers during refactoring; only extract a custom hook when it truly orchestrates multiple queries/mutations or shared derived state.
**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 │
│ 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/docs/test.md` - Testing specification

View File

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

View File

@ -1,477 +0,0 @@
# 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
}
function ConfigurationHeader({
isAdvancedMode,
onPublish,
}: ConfigurationHeaderProps) {
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
function AppInfoExpanded({ appDetail, onAction }: AppInfoViewProps) {
return (
<div className="expanded">
{/* Clean, focused expanded view */}
</div>
)
}
function AppInfoCollapsed({ appDetail, onAction }: AppInfoViewProps) {
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
}
function AppInfoModals({
appDetail,
activeModal,
onClose,
onSuccess,
}: AppInfoModalsProps) {
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
}
function OperationItem({ operation, onAction }: OperationItemProps) {
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
}
function Child({ value, onChange, onSubmit }: ChildProps) {
return (
<div>
<input value={value} onChange={e => onChange(e.target.value)} />
<button onClick={onSubmit}>Submit</button>
</div>
)
}
```
### Render Props for Flexibility
When sub-components need parent context:
```typescript
interface ListProps<T> {
items: T[]
renderItem: (item: T, index: number) => React.ReactNode
renderEmpty?: () => React.ReactNode
}
function List<T>({ items, renderItem, renderEmpty }: ListProps<T>) {
if (items.length === 0 && renderEmpty) {
return <>{renderEmpty()}</>
}
return (
<div>
{items.map((item, index) => renderItem(item, index))}
</div>
)
}
// Usage
<List
items={operations}
renderItem={(op, i) => <OperationItem key={i} operation={op} />}
renderEmpty={() => <EmptyState message="No operations" />}
/>
```

View File

@ -1,281 +0,0 @@
# 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
function Configuration() {
const [modelConfig, setModelConfig] = useState<ModelConfig>(...)
// ... lots of related state and effects
}
// After: Clean component
function Configuration() {
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 / Mutation Hooks
When hook extraction touches query or mutation code, do not use this reference as the source of truth for data-layer patterns.
- Do not introduce deprecated `useInvalid` / `useReset`.
- Do not extract thin passthrough `useQuery` hooks; only extract orchestration hooks.
### 2. Form State Hook
```typescript
// Pattern: Form state + validation + submission
export const useConfigForm = (initialValues: ConfigFormValues) => {
const [values, setValues] = useState(initialValues)
const [errors, setErrors] = useState<Record<string, string>>({})
const [isSubmitting, setIsSubmitting] = useState(false)
const validate = useCallback(() => {
const newErrors: Record<string, string> = {}
if (!values.name) newErrors.name = 'Name is required'
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}, [values])
const handleChange = useCallback((field: string, value: any) => {
setValues(prev => ({ ...prev, [field]: value }))
}, [])
const handleSubmit = useCallback(async (onSubmit: (values: ConfigFormValues) => Promise<void>) => {
if (!validate()) return
setIsSubmitting(true)
try {
await onSubmit(values)
} finally {
setIsSubmitting(false)
}
}, [values, validate])
return { values, errors, isSubmitting, handleChange, handleSubmit }
}
```
### 3. Modal State Hook
```typescript
// Pattern: Multiple modal management
type ModalType = 'edit' | 'delete' | 'duplicate' | null
export const useModalState = () => {
const [activeModal, setActiveModal] = useState<ModalType>(null)
const [modalData, setModalData] = useState<any>(null)
const openModal = useCallback((type: ModalType, data?: any) => {
setActiveModal(type)
setModalData(data)
}, [])
const closeModal = useCallback(() => {
setActiveModal(null)
setModalData(null)
}, [])
return {
activeModal,
modalData,
openModal,
closeModal,
isOpen: useCallback((type: ModalType) => activeModal === type, [activeModal]),
}
}
```
### 4. Toggle/Boolean Hook
```typescript
// Pattern: Boolean state with convenience methods
export const useToggle = (initialValue = false) => {
const [value, setValue] = useState(initialValue)
const toggle = useCallback(() => setValue(v => !v), [])
const setTrue = useCallback(() => setValue(true), [])
const setFalse = useCallback(() => setValue(false), [])
return [value, { toggle, setTrue, setFalse, set: setValue }] as const
}
// Usage
const [isExpanded, { toggle, setTrue: expand, setFalse: collapse }] = useToggle()
```
## Testing Extracted Hooks
After extraction, test hooks in isolation:
```typescript
// use-model-config.spec.ts
import { renderHook, act } from '@testing-library/react'
import { useModelConfig } from './use-model-config'
describe('useModelConfig', () => {
it('should initialize with default values', () => {
const { result } = renderHook(() => useModelConfig({
hasFetchedDetail: false,
}))
expect(result.current.modelConfig.provider).toBe('langgenius/openai/openai')
expect(result.current.modelModeType).toBe(ModelModeType.unset)
})
it('should update model config', () => {
const { result } = renderHook(() => useModelConfig({
hasFetchedDetail: true,
}))
act(() => {
result.current.setModelConfig({
...result.current.modelConfig,
model_id: 'gpt-4',
})
})
expect(result.current.modelConfig.model_id).toBe('gpt-4')
})
})
```

View File

@ -1,6 +1,6 @@
---
name: how-to-write-component
description: React/TypeScript component style guide. Use when writing, refactoring, or reviewing React components, especially around props typing, state boundaries, shared local state with Jotai atoms, API types, query/mutation contracts, navigation, memoization, wrappers, and empty-state handling.
description: React/TypeScript component style guide. Use when writing, refactoring, or reviewing React components, especially around abstraction choices, props typing, state boundaries, shared local state with Jotai atoms, API types, query/mutation contracts, navigation, memoization, wrappers, and empty-state handling.
---
# How To Write A Component
@ -12,26 +12,79 @@ Use this as the decision guide for React/TypeScript component structure. Existin
- Search before adding UI, hooks, helpers, or styling patterns. Reuse existing base components, feature components, hooks, utilities, and design styles when they fit.
- Group code by feature workflow, route, or ownership area: components, hooks, local types, query helpers, atoms, constants, and small utilities should live near the code that changes with them.
- Promote code to shared only when multiple verticals need the same stable primitive. Otherwise keep it local and compose shared primitives inside the owning feature.
- Prefer local code and purpose-named helpers over catch-all utility modules; do not group workflow-specific defaults, validation, payload shaping, or metadata merging in a generic utils file just because they share a DTO.
- Keep source/default selection, validation, and payload shaping close to the workflow that owns the behavior. Do not extract a shared helper just because two flows read the same DTO when their priority order, fallback behavior, or submit semantics differ.
- Prefer direct, readable conditionals at the use site for small branch-specific decisions, especially form source selection and request payload assembly. Extract only when the helper name captures a stable domain rule and removes repeated complexity without hiding flow-specific behavior.
- When fixing an invalid pattern, scan the touched feature or branch for equivalent patterns and fix them together.
- Follow Dify's CSS-first Tailwind v4 contract from `packages/dify-ui/README.md` and `packages/dify-ui/AGENTS.md`. Prefer design-system tokens, utilities, and radius mappings over generic Tailwind guidance.
## Feature Workflow Layout
- State-heavy wizards, drawers, modals, and secondary workflows work best as a small feature surface with route/entry files, a single feature-local state file, and feature-local UI.
- Keep `ui/` shallow with owner files that map to the workflow's real composition boundaries and major visual regions.
- Owner files contain the section components, field components, skeletons, and one-off helper components that belong to their visual region.
- Folders represent groups of related files with a shared owner and a stable reason to change together.
- The entry file handles route integration, provider wiring, close behavior, and feature surface mounting. The composition owner handles high-level workflow branching, and the closest visual owner handles section branching.
## Ownership
- Put local state, queries, mutations, handlers, and derived UI data in the lowest component that uses them. Extract a purpose-built owner component only when the logic has no natural home.
- Repeated TanStack query calls in sibling components are acceptable when each component independently consumes the data. Do not hoist a query only because it is duplicated; TanStack Query handles deduplication and cache sharing.
- Hoist state, queries, or callbacks to a parent only when the parent consumes the data, coordinates shared loading/error/empty UI, needs one consistent snapshot, or owns a workflow spanning children.
- Pass stable domain identity across boundaries; avoid forwarding derived presentation state when the receiver can derive it from its own data source. A component that owns a visual surface should also own the data access, loading, empty, and error states for content rendered inside it unless a parent truly coordinates that state.
- Loading states for visual surfaces should use skeleton placeholders scoped to the content that is actually loading, with shape, density, and dimensions close to the final UI. Avoid generic loading text or centered spinners for page sections, cards, lists, tables, forms, and drawers; reserve spinners for small inline busy indicators such as an in-progress status icon.
- Avoid prop drilling. One pass-through layer is acceptable; repeated forwarding means ownership should move down or into feature-scoped Jotai UI state. Keep server/cache state in query and API data flow.
- Do not replace prop drilling with one top-level hook that returns a large view model and then thread that object through section props. Move each hook, query, derived value, and handler to the concrete section that consumes it, or use feature-scoped Jotai atoms for simple shared form/UI state when siblings need the same source of truth.
- When using feature-scoped Jotai state for a form, drawer, or other secondary surface, scope the store to that surface instance when stale cross-instance state is possible. Initialize stable config at the owning boundary, then let descendants read only the atoms or purpose-named hooks they actually need.
- Keep callbacks in a parent only for workflow coordination such as form submission, shared selection, batch behavior, or navigation. Otherwise let the child or row own its action.
- Prefer uncontrolled DOM state and CSS variables before adding controlled props.
## Feature-Scoped Jotai State
- A module's feature-local state lives in one state file for Jotai-backed features: primitive atoms, query atoms, derived atoms, write-only action atoms, mutation atoms, submission orchestration, provider exports, and optional scope configuration.
- Atom order in the state file follows the dependency graph: types/constants, editable primitives, query atoms, query-data derived atoms, readiness/business derived atoms, write actions, mutation atoms, submission orchestration, provider exports.
- Derived atom names read as business facts. Write atom names read as user or workflow commands.
- UI components read and write the exact atom they use with `useAtomValue` or `useSetAtom`. Repeated workflow semantics live in named derived atoms or write atoms.
- Non-query derived atoms return a narrow value with a clear domain name. Query atoms expose the TanStack Query result object so loading, error, fetch, and pagination state stay attached to the query contract.
- Write-only atoms own state transitions that update multiple primitives, reset dependent state, guard stale async work, or advance the workflow.
- `jotai-tanstack-query` atoms use the same QueryClient as the React Query provider. Query atoms belong in feature state when atoms are the feature's local state surface.
- Jotai scope is an optional instance-isolation tool for secondary surfaces with independent local state. Query atoms keep shared cache behavior through the shared QueryClient.
## Components, Props, And Types
- Type component signatures directly; do not use `FC` or `React.FC`.
- Prefer `function` for top-level components and module helpers. Use arrow functions for local callbacks, handlers, and lambda-style APIs.
- Prefer named exports. Use default exports only where the framework requires them, such as Next.js route files.
- Type simple one-off props inline. Use a named `Props` type only when reused, exported, complex, or clearer.
- Use API-generated or API-returned types at component boundaries. Keep small UI conversion helpers beside the component that needs them.
- Name values by their domain role and backend API contract, and keep that name stable across the call chain, especially IDs like `appInstanceId`. Normalize framework or route params at the boundary.
- Keep fallback and invariant checks at the lowest component that already handles that state; callers should pass raw values through instead of duplicating checks.
- Use API-generated or API-returned types at component boundaries. Keep small UI conversion helpers and one-off UI extensions beside the component that needs them.
- Do not create type aliases that only rename another type. Use an alias only when it encodes a real UI concept, refinement, or reusable local contract.
- Name values by their domain role and backend API contract, and keep that name stable across the call chain, especially persistent IDs and route params. Normalize framework or route params at the boundary.
- Keep fallback and invariant checks at the lowest component that already handles that state; avoid defensive fallbacks that mask impossible states.
- Do not extract fallback helpers whose only behavior is hiding missing display data. The component that renders the surface owns the empty, disabled, hidden, or placeholder state.
## Generated API Contracts
- Treat generated contracts as authoritative at API, query, mutation, cache, and service boundaries. For enterprise APIs, use `packages/contracts/generated/enterprise/*`.
- Do not hand-write local request/response/reply/page/cache-data types that mirror generated DTOs. Import or infer the generated type.
- Do not widen generated fields or enums for compatibility. Normalize legacy input at the boundary, then return the generated field type.
- Do not repair generated or API-returned contract fields in components unless the API contract or product requirement says they need normalization. Treat enums, statuses, and presence flags as exact contract values.
- Use generated enum objects and union types directly in props, comparisons, status logic, and i18n keys. Do not add local enum constants or parallel frontend enum/status layers unless they model real product state not represented by the API. Presentation-only tone maps should be keyed by the generated enum.
- Normalize or coerce only at a real boundary, such as user-entered forms, search, URL/query params, file names, DOM IDs, or legacy adapters. Preserve user-entered values when whitespace or formatting can be meaningful.
- Do not coerce nullable or optional API strings to `''` in query, derived model, or payload-building code. Keep `undefined` or `null` until the final boundary that requires a string.
- Local UI models are fine for presentation, form state, select options, or guarded required-field refinements. Name them as UI concepts, not generated DTO mirrors.
- Required-value refinements are allowed only after same-branch filtering or early return. Prefer nullable-tolerant props for render-only data.
- When a component needs a stricter shape than a generated DTO, refine once at the API/query-to-UI boundary into a purpose-named UI type instead of hiding missing fields with generic fallback or coercion helpers.
## Nullable API Data
- Prefer nullable-tolerant call boundaries. Pass API-returned types through for render-only rows, and let the component render fallback, disabled state, or nothing.
- Narrow only where a real value is required, such as mutation params, route hrefs, select values, or query input. Build that target model with `flatMap`, a local loop, or an early return so the required value is captured in the same branch.
- If design says a field is the display value, use that field. Only the final component should decide whether a nullable display value renders a placeholder, hides content, or disables an action.
- Do not wrap required arrays or fields in null-fallback helpers. Use empty collection fallbacks only for not-yet-loaded query data or genuinely nullable collections at the owning render boundary.
- Do not drop rows only to satisfy props or React keys; use a stable fallback key when possible.
- Use conditional spreads or explicit pushes for conditional array items instead of `undefined` placeholders followed by a narrowing filter.
- Avoid truthiness type guards, `filter(Boolean)`, `filter(item => item.id)`, and `!` after those filters.
- Use type guards only for meaningful domain or runtime validation, such as enum membership, object shape, or a reusable business invariant.
## Queries And Mutations
@ -39,7 +92,8 @@ Use this as the decision guide for React/TypeScript component structure. Existin
- Consume queries directly with `useQuery(consoleQuery.xxx.queryOptions(...))` or `useQuery(marketplaceQuery.xxx.queryOptions(...))`.
- Avoid pass-through hooks and thin `web/service/use-*` wrappers that only rename `queryOptions()` or `mutationOptions()`. Extract a small `queryOptions` helper only when repeated call-site options justify it.
- Keep feature hooks for real orchestration, workflow state, or shared domain behavior.
- For missing required query input, use `input: skipToken`; use `enabled` only for extra business gating after the input is valid.
- For TanStack cache data, use generated or query-derived types; do not create local wrappers for `getQueryData` or `getQueriesData`.
- For generated oRPC `queryOptions()` / `infiniteOptions()`, do not pass `skipToken` as `input`; keep a valid placeholder input shape and use `enabled` to gate missing required params because the OpenAPI codec encodes input eagerly.
- Consume mutations directly with `useMutation(consoleQuery.xxx.mutationOptions(...))` or `useMutation(marketplaceQuery.xxx.mutationOptions(...))`; use oRPC clients as `mutationFn` only for custom flows.
- Put shared cache behavior in `createTanstackQueryUtils(...experimental_defaults...)`; components may add UI feedback callbacks, but should not own shared invalidation rules.
- Do not use deprecated `useInvalid` or `useReset`.
@ -48,12 +102,13 @@ Use this as the decision guide for React/TypeScript component structure. Existin
## Component Boundaries
- Use the first level below a page or tab to organize independent page sections when it adds real structure. This layer is layout/semantic first, not automatically the data owner.
- Treat component names, semantic roles, and user- or design-marked visual regions as boundary constraints. Do not expand a child component's responsibility just because its data is useful nearby; keep adjacent UI as a sibling owner or introduce a correctly named broader owner.
- Split deeper components by the data and state each layer actually needs. Each component should access only necessary data, and ownership should stay at the lowest consumer.
- Keep cohesive forms, menu bodies, and one-off helpers local unless they need their own state, reuse, or semantic boundary.
- Separate hidden secondary surfaces from the trigger's main flow. For dialogs, dropdowns, popovers, and similar branches, extract a small local component that owns the trigger, open state, and hidden content when it would obscure the parent flow.
- Preserve composability by separating behavior ownership from layout ownership. A dropdown action may own its trigger, open state, and menu content; the caller owns placement such as slots, offsets, and alignment.
- Avoid unnecessary DOM hierarchy. Do not add wrapper elements unless they provide layout, semantics, accessibility, state ownership, or integration with a library API; prefer fragments or styling an existing element when possible.
- Avoid shallow wrappers and prop renaming unless the wrapper adds validation, orchestration, error handling, state ownership, or a real semantic boundary.
- Avoid shallow wrappers, hook-to-props adapter components, layout-only render-prop wrappers, and prop renaming unless the wrapper adds validation, orchestration, error handling, state ownership, or a real semantic boundary. If a component only calls a hook and forwards every returned field to one child, move the hook into that child or make the wrapper own a real surface.
## You Might Not Need An Effect
@ -68,4 +123,6 @@ Use this as the decision guide for React/TypeScript component structure. Existin
## Navigation And Performance
- Prefer `Link` for normal navigation. Use router APIs only for command-flow side effects such as mutation success, guarded redirects, or form submission.
- Before reaching for `memo`, first try moving changing state down to the smallest component that actually uses it so unrelated sibling trees stay untouched.
- If changing state must wrap other content, lift the unchanged content up and pass it as `children` so the stateful wrapper can update without React visiting that subtree.
- Avoid `memo`, `useMemo`, and `useCallback` unless there is a clear performance reason.

View File

@ -16,6 +16,7 @@ api = ExternalApi(
inner_api_ns = Namespace("inner_api", description="Internal API operations", path="/")
from . import mail as _mail
from . import runtime_credentials as _runtime_credentials
from .app import dsl as _app_dsl
from .knowledge import retrieval as _knowledge_retrieval
from .plugin import agent_drive as _agent_drive
@ -30,6 +31,7 @@ __all__ = [
"_knowledge_retrieval",
"_mail",
"_plugin",
"_runtime_credentials",
"_workspace",
"api",
"bp",

View File

@ -0,0 +1,205 @@
"""Inner API endpoints for runtime credential resolution.
Called by Enterprise while resolving AppRunner runtime artifacts. The endpoint
returns decrypted model and tool credentials for in-memory runtime use only.
"""
import json
import logging
from json import JSONDecodeError
from typing import Any
from flask_restx import Resource
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import select
from sqlalchemy.orm import Session
from controllers.common.schema import register_schema_model
from controllers.console.wraps import setup_required
from controllers.inner_api import inner_api_ns
from controllers.inner_api.wraps import enterprise_inner_api_only
from core.helper import encrypter
from core.helper.provider_cache import ToolProviderCredentialsCache
from core.helper.provider_encryption import create_provider_encrypter
from core.plugin.impl.model_runtime_factory import create_plugin_provider_manager
from core.tools.tool_manager import ToolManager
from extensions.ext_database import db
from models.provider import ProviderCredential
from models.tools import BuiltinToolProvider
logger = logging.getLogger(__name__)
_KIND_MODEL = "model"
_KIND_TOOL = "tool"
# (body, status) pair returned by a resolver helper when resolution fails.
ResolveError = tuple[dict[str, str], int]
class InnerRuntimeCredentialResolveItem(BaseModel):
model_config = ConfigDict(extra="forbid")
credential_id: str = Field(description="Credential id")
provider: str = Field(description="Runtime provider identifier, for example langgenius/openai/openai")
kind: str = Field(description="Credential kind, either 'model' or 'tool'")
class InnerRuntimeCredentialsResolvePayload(BaseModel):
model_config = ConfigDict(extra="forbid")
tenant_id: str = Field(description="Workspace id")
credentials: list[InnerRuntimeCredentialResolveItem] = Field(default_factory=list)
register_schema_model(inner_api_ns, InnerRuntimeCredentialsResolvePayload)
@inner_api_ns.route("/enterprise/credentials/resolve")
class EnterpriseRuntimeCredentialsResolve(Resource):
@setup_required
@enterprise_inner_api_only
@inner_api_ns.doc(
"enterprise_runtime_credentials_resolve",
responses={
200: "Credentials resolved",
400: "Invalid request or credential config",
404: "Provider or credential not found",
},
)
@inner_api_ns.expect(inner_api_ns.models[InnerRuntimeCredentialsResolvePayload.__name__])
def post(self):
args = InnerRuntimeCredentialsResolvePayload.model_validate(inner_api_ns.payload or {})
if not args.credentials:
return {"credentials": []}, 200
# Model resolution shares one provider configuration set; build it lazily
# so a tool-only request never pays for the plugin daemon round trip.
model_configurations = None
resolved: list[dict[str, Any]] = []
for item in args.credentials:
if item.kind == _KIND_MODEL:
if model_configurations is None:
provider_manager = create_plugin_provider_manager(tenant_id=args.tenant_id)
model_configurations = provider_manager.get_configurations(args.tenant_id)
values, error = _resolve_model(args.tenant_id, model_configurations, item)
elif item.kind == _KIND_TOOL:
values, error = _resolve_tool(args.tenant_id, item)
else:
return {"message": f"unsupported credential kind '{item.kind}'"}, 400
if error is not None:
return error
resolved.append(
{
"credential_id": item.credential_id,
"kind": item.kind,
"provider": item.provider,
"values": values,
}
)
return {"credentials": resolved}, 200
def _resolve_model(
tenant_id: str, provider_configurations: Any, item: InnerRuntimeCredentialResolveItem
) -> tuple[dict[str, Any] | None, ResolveError | None]:
provider_configuration = provider_configurations.get(item.provider)
if provider_configuration is None:
return None, ({"message": f"provider '{item.provider}' not found"}, 404)
provider_schema = provider_configuration.provider.provider_credential_schema
secret_variables = provider_configuration.extract_secret_variables(
provider_schema.credential_form_schemas if provider_schema else []
)
with Session(db.engine) as session:
stmt = select(ProviderCredential).where(
ProviderCredential.id == item.credential_id,
ProviderCredential.tenant_id == tenant_id,
ProviderCredential.provider_name.in_(provider_configuration._get_provider_names()),
)
credential = session.execute(stmt).scalar_one_or_none()
if credential is None or not credential.encrypted_config:
return None, ({"message": f"credential '{item.credential_id}' not found"}, 404)
try:
values = json.loads(credential.encrypted_config)
except JSONDecodeError:
return None, ({"message": f"credential '{item.credential_id}' has invalid config"}, 400)
if not isinstance(values, dict):
return None, ({"message": f"credential '{item.credential_id}' has invalid config"}, 400)
for key in secret_variables:
value = values.get(key)
if value is None:
continue
try:
values[key] = encrypter.decrypt_token(tenant_id=tenant_id, token=value)
except Exception as exc:
logger.warning(
"failed to resolve runtime model credential",
extra={
"credential_id": item.credential_id,
"provider": item.provider,
"tenant_id": tenant_id,
"error": type(exc).__name__,
},
)
return None, ({"message": f"credential '{item.credential_id}' decrypt failed"}, 400)
return values, None
def _resolve_tool(
tenant_id: str, item: InnerRuntimeCredentialResolveItem
) -> tuple[dict[str, Any] | None, ResolveError | None]:
try:
provider_controller = ToolManager.get_builtin_provider(item.provider, tenant_id)
except Exception as exc:
logger.warning(
"failed to load runtime tool provider",
extra={"provider": item.provider, "tenant_id": tenant_id, "error": type(exc).__name__},
)
return None, ({"message": f"tool provider '{item.provider}' not found"}, 404)
with Session(db.engine) as session:
stmt = select(BuiltinToolProvider).where(
BuiltinToolProvider.id == item.credential_id,
BuiltinToolProvider.provider == item.provider,
BuiltinToolProvider.tenant_id == tenant_id,
)
builtin_provider = session.execute(stmt).scalar_one_or_none()
if builtin_provider is None:
return None, ({"message": f"credential '{item.credential_id}' not found"}, 404)
try:
# Tool credentials are stored as a single encrypted dict; the secret
# fields are decided by the schema bound to this credential type.
provider_encrypter, _ = create_provider_encrypter(
tenant_id=tenant_id,
config=[
schema.to_basic_provider_config()
for schema in provider_controller.get_credentials_schema_by_type(builtin_provider.credential_type)
],
cache=ToolProviderCredentialsCache(
tenant_id=tenant_id, provider=item.provider, credential_id=builtin_provider.id
),
)
values = dict(provider_encrypter.decrypt(builtin_provider.credentials))
except Exception as exc:
logger.warning(
"failed to resolve runtime tool credential",
extra={
"credential_id": item.credential_id,
"provider": item.provider,
"tenant_id": tenant_id,
"error": type(exc).__name__,
},
)
return None, ({"message": f"credential '{item.credential_id}' decrypt failed"}, 400)
return values, None

View File

@ -18454,6 +18454,7 @@ Model class for provider system configuration response.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| branding | [BrandingModel](#brandingmodel) | | Yes |
| enable_app_deploy | boolean | | Yes |
| enable_change_email | boolean, <br>**Default:** true | | Yes |
| enable_collaboration_mode | boolean, <br>**Default:** true | | Yes |
| enable_creators_platform | boolean | | Yes |

View File

@ -1597,6 +1597,7 @@ Default configuration for form inputs.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| branding | [BrandingModel](#brandingmodel) | | Yes |
| enable_app_deploy | boolean | | Yes |
| enable_change_email | boolean, <br>**Default:** true | | Yes |
| enable_collaboration_mode | boolean, <br>**Default:** true | | Yes |
| enable_creators_platform | boolean | | Yes |

View File

@ -160,6 +160,7 @@ class PluginManagerModel(FeatureResponseModel):
class SystemFeatureModel(FeatureResponseModel):
enable_app_deploy: bool = False
sso_enforced_for_signin: bool = False
sso_enforced_for_signin_protocol: str = ""
enable_marketplace: bool = False
@ -419,6 +420,9 @@ class FeatureService:
if "IsAllowCreateWorkspace" in enterprise_info:
features.is_allow_create_workspace = enterprise_info["IsAllowCreateWorkspace"]
if "EnableAppDeploy" in enterprise_info:
features.enable_app_deploy = enterprise_info["EnableAppDeploy"]
if "Branding" in enterprise_info:
features.branding.application_title = enterprise_info["Branding"].get("applicationTitle", "")
features.branding.login_page_logo = enterprise_info["Branding"].get("loginPageLogo", "")

View File

@ -61,6 +61,7 @@ class TestFeatureService:
},
"WebAppAuth": {"allowSso": True, "allowEmailCodeLogin": True, "allowEmailPasswordLogin": False},
"SSOEnforcedForWebProtocol": "oidc",
"EnableAppDeploy": True,
"License": {
"status": "active",
"expiredAt": "2025-12-31",
@ -291,6 +292,7 @@ class TestFeatureService:
assert isinstance(result, SystemFeatureModel)
# Verify enterprise features
assert result.enable_app_deploy is True
assert result.branding.enabled is True
assert result.webapp_auth.enabled is True
assert result.enable_change_email is False
@ -377,6 +379,7 @@ class TestFeatureService:
# Ensure that data required for frontend rendering remains accessible.
# Branding should match the mock data
assert result.enable_app_deploy is True
assert result.branding.enabled is True
assert result.branding.application_title == "Test Enterprise"
assert result.branding.login_page_logo == "https://example.com/logo.png"
@ -424,6 +427,7 @@ class TestFeatureService:
assert isinstance(result, SystemFeatureModel)
# Verify basic configuration
assert result.enable_app_deploy is False
assert result.branding.enabled is False
assert result.webapp_auth.enabled is False
assert result.enable_change_email is True
@ -625,6 +629,7 @@ class TestFeatureService:
assert isinstance(result, SystemFeatureModel)
# Verify enterprise features are disabled
assert result.enable_app_deploy is False
assert result.branding.enabled is False
assert result.webapp_auth.enabled is False
assert result.enable_change_email is True

View File

@ -0,0 +1,208 @@
"""Unit tests for runtime credential inner API."""
import inspect
from unittest.mock import MagicMock, patch
from flask import Flask
from controllers.inner_api.runtime_credentials import (
EnterpriseRuntimeCredentialsResolve,
InnerRuntimeCredentialsResolvePayload,
)
def test_runtime_credentials_payload_accepts_items():
payload = InnerRuntimeCredentialsResolvePayload.model_validate(
{
"tenant_id": "tenant-1",
"credentials": [
{
"credential_id": "credential-1",
"provider": "langgenius/openai/openai",
"kind": "model",
}
],
}
)
assert payload.tenant_id == "tenant-1"
assert payload.credentials[0].provider == "langgenius/openai/openai"
assert payload.credentials[0].kind == "model"
@patch("controllers.inner_api.runtime_credentials.encrypter.decrypt_token")
@patch("controllers.inner_api.runtime_credentials.db")
@patch("controllers.inner_api.runtime_credentials.Session")
@patch("controllers.inner_api.runtime_credentials.create_plugin_provider_manager")
def test_runtime_model_credentials_resolve_returns_decrypted_values(
mock_provider_manager_factory,
mock_session_cls,
mock_db,
mock_decrypt_token,
app: Flask,
):
provider_configuration = MagicMock()
provider_configuration.provider.provider_credential_schema.credential_form_schemas = []
provider_configuration.extract_secret_variables.return_value = ["openai_api_key"]
provider_configuration._get_provider_names.return_value = ["langgenius/openai/openai", "openai"]
provider_configurations = MagicMock()
provider_configurations.get.return_value = provider_configuration
provider_manager = MagicMock()
provider_manager.get_configurations.return_value = provider_configurations
mock_provider_manager_factory.return_value = provider_manager
credential = MagicMock()
credential.encrypted_config = '{"openai_api_key":"encrypted","api_base":"https://api.openai.com/v1"}'
session = MagicMock()
session.__enter__.return_value = session
session.__exit__.return_value = False
session.execute.return_value.scalar_one_or_none.return_value = credential
mock_session_cls.return_value = session
mock_db.engine = MagicMock()
mock_decrypt_token.return_value = "sk-test"
handler = EnterpriseRuntimeCredentialsResolve()
unwrapped = inspect.unwrap(handler.post)
with app.test_request_context():
with patch("controllers.inner_api.runtime_credentials.inner_api_ns") as mock_ns:
mock_ns.payload = {
"tenant_id": "tenant-1",
"credentials": [
{
"credential_id": "credential-1",
"provider": "langgenius/openai/openai",
"kind": "model",
}
],
}
body, status_code = unwrapped(handler)
assert status_code == 200
assert body["credentials"][0]["kind"] == "model"
assert body["credentials"][0]["values"]["openai_api_key"] == "sk-test"
assert body["credentials"][0]["values"]["api_base"] == "https://api.openai.com/v1"
mock_decrypt_token.assert_called_once_with(tenant_id="tenant-1", token="encrypted")
@patch("controllers.inner_api.runtime_credentials.create_plugin_provider_manager")
def test_runtime_model_credentials_resolve_rejects_unknown_provider(mock_provider_manager_factory, app: Flask):
provider_configurations = MagicMock()
provider_configurations.get.return_value = None
provider_manager = MagicMock()
provider_manager.get_configurations.return_value = provider_configurations
mock_provider_manager_factory.return_value = provider_manager
handler = EnterpriseRuntimeCredentialsResolve()
unwrapped = inspect.unwrap(handler.post)
with app.test_request_context():
with patch("controllers.inner_api.runtime_credentials.inner_api_ns") as mock_ns:
mock_ns.payload = {
"tenant_id": "tenant-1",
"credentials": [{"credential_id": "credential-1", "provider": "missing", "kind": "model"}],
}
body, status_code = unwrapped(handler)
assert status_code == 404
assert "provider" in body["message"]
@patch("controllers.inner_api.runtime_credentials.create_provider_encrypter")
@patch("controllers.inner_api.runtime_credentials.ToolProviderCredentialsCache")
@patch("controllers.inner_api.runtime_credentials.db")
@patch("controllers.inner_api.runtime_credentials.Session")
@patch("controllers.inner_api.runtime_credentials.ToolManager")
def test_runtime_tool_credentials_resolve_returns_decrypted_values(
mock_tool_manager,
mock_session_cls,
mock_db,
mock_cache_cls,
mock_create_encrypter,
app: Flask,
):
provider_controller = MagicMock()
provider_controller.get_credentials_schema_by_type.return_value = []
mock_tool_manager.get_builtin_provider.return_value = provider_controller
builtin_provider = MagicMock()
builtin_provider.id = "credential-1"
session = MagicMock()
session.__enter__.return_value = session
session.__exit__.return_value = False
session.execute.return_value.scalar_one_or_none.return_value = builtin_provider
mock_session_cls.return_value = session
mock_db.engine = MagicMock()
provider_encrypter = MagicMock()
provider_encrypter.decrypt.return_value = {"tavily_api_key": "tvly-secret"}
mock_create_encrypter.return_value = (provider_encrypter, MagicMock())
handler = EnterpriseRuntimeCredentialsResolve()
unwrapped = inspect.unwrap(handler.post)
with app.test_request_context():
with patch("controllers.inner_api.runtime_credentials.inner_api_ns") as mock_ns:
mock_ns.payload = {
"tenant_id": "tenant-1",
"credentials": [
{
"credential_id": "credential-1",
"provider": "langgenius/tavily/tavily",
"kind": "tool",
}
],
}
body, status_code = unwrapped(handler)
assert status_code == 200
assert body["credentials"][0]["kind"] == "tool"
assert body["credentials"][0]["provider"] == "langgenius/tavily/tavily"
assert body["credentials"][0]["values"]["tavily_api_key"] == "tvly-secret"
compiled = str(session.execute.call_args.args[0].compile(compile_kwargs={"literal_binds": True}))
assert "tool_builtin_providers.provider = 'langgenius/tavily/tavily'" in compiled
@patch("controllers.inner_api.runtime_credentials.db")
@patch("controllers.inner_api.runtime_credentials.Session")
@patch("controllers.inner_api.runtime_credentials.ToolManager")
def test_runtime_tool_credentials_resolve_rejects_unknown_credential(
mock_tool_manager,
mock_session_cls,
mock_db,
app: Flask,
):
mock_tool_manager.get_builtin_provider.return_value = MagicMock()
session = MagicMock()
session.__enter__.return_value = session
session.__exit__.return_value = False
session.execute.return_value.scalar_one_or_none.return_value = None
mock_session_cls.return_value = session
mock_db.engine = MagicMock()
handler = EnterpriseRuntimeCredentialsResolve()
unwrapped = inspect.unwrap(handler.post)
with app.test_request_context():
with patch("controllers.inner_api.runtime_credentials.inner_api_ns") as mock_ns:
mock_ns.payload = {
"tenant_id": "tenant-1",
"credentials": [{"credential_id": "missing", "provider": "langgenius/tavily/tavily", "kind": "tool"}],
}
body, status_code = unwrapped(handler)
assert status_code == 404
assert "credential" in body["message"]
def test_runtime_credentials_resolve_rejects_unknown_kind(app: Flask):
handler = EnterpriseRuntimeCredentialsResolve()
unwrapped = inspect.unwrap(handler.post)
with app.test_request_context():
with patch("controllers.inner_api.runtime_credentials.inner_api_ns") as mock_ns:
mock_ns.payload = {
"tenant_id": "tenant-1",
"credentials": [{"credential_id": "credential-1", "provider": "x", "kind": "secret"}],
}
body, status_code = unwrapped(handler)
assert status_code == 400
assert "kind" in body["message"]

View File

@ -0,0 +1,37 @@
import pytest
from services import feature_service as feature_service_module
from services.feature_service import FeatureService, SystemFeatureModel
@pytest.mark.parametrize(
("enterprise_info", "initial", "expected"),
[
# Enterprise reports the feature on -> mirrored through.
({"EnableAppDeploy": True}, False, True),
# Enterprise may turn it off; the read runs after the hardcoded default
# and overrides it (forward-compat with a future entitlement gate).
({"EnableAppDeploy": False}, True, False),
# Old enterprise without the key -> the existing value is left untouched.
({}, True, True),
],
ids=["enabled", "override_off", "missing_keeps_default"],
)
def test_fulfill_params_from_enterprise_enable_app_deploy(
monkeypatch: pytest.MonkeyPatch,
enterprise_info: dict,
initial: bool,
expected: bool,
):
monkeypatch.setattr(
feature_service_module.EnterpriseService,
"get_info",
staticmethod(lambda: enterprise_info),
)
features = SystemFeatureModel()
features.enable_app_deploy = initial
FeatureService._fulfill_params_from_enterprise(features)
assert features.enable_app_deploy is expected

View File

@ -487,14 +487,6 @@
"count": 1
}
},
"web/app/components/app/app-access-control/access-control-item.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
},
"jsx-a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/app/app-publisher/sections.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
@ -7649,11 +7641,6 @@
"count": 1
}
},
"web/models/access-control.ts": {
"erasable-syntax-only/enums": {
"count": 2
}
},
"web/models/app.ts": {
"erasable-syntax-only/enums": {
"count": 2
@ -8051,11 +8038,6 @@
"count": 17
}
},
"web/utils/clipboard.ts": {
"ts/no-explicit-any": {
"count": 1
}
},
"web/utils/completion-params.spec.ts": {
"ts/no-explicit-any": {
"count": 3

View File

@ -6,6 +6,7 @@ export type ClientOptions = {
export type SystemFeatureModel = {
branding: BrandingModel
enable_app_deploy: boolean
enable_change_email: boolean
enable_collaboration_mode: boolean
enable_creators_platform: boolean

View File

@ -98,6 +98,7 @@ export const zSystemFeatureModel = z.object({
login_page_logo: '',
workspace_logo: '',
}),
enable_app_deploy: z.boolean().default(false),
enable_change_email: z.boolean().default(true),
enable_collaboration_mode: z.boolean().default(true),
enable_creators_platform: z.boolean().default(false),

View File

@ -540,6 +540,7 @@ export type SuggestedQuestionsResponse = {
export type SystemFeatureModel = {
branding: BrandingModel
enable_app_deploy: boolean
enable_change_email: boolean
enable_collaboration_mode: boolean
enable_creators_platform: boolean

View File

@ -824,6 +824,7 @@ export const zSystemFeatureModel = z.object({
login_page_logo: '',
workspace_logo: '',
}),
enable_app_deploy: z.boolean().default(false),
enable_change_email: z.boolean().default(true),
enable_collaboration_mode: z.boolean().default(true),
enable_creators_platform: z.boolean().default(false),

View File

@ -4,9 +4,97 @@ import { oc } from '@orpc/contract'
import * as z from 'zod'
import {
zAccessServiceCreateApiKeyBody,
zAccessServiceCreateApiKeyPath,
zAccessServiceCreateApiKeyResponse,
zAccessServiceDeleteApiKeyPath,
zAccessServiceDeleteApiKeyResponse,
zAccessServiceGetAccessChannelsPath,
zAccessServiceGetAccessChannelsResponse,
zAccessServiceGetAccessPolicyPath,
zAccessServiceGetAccessPolicyResponse,
zAccessServiceGetAccessSettingsPath,
zAccessServiceGetAccessSettingsResponse,
zAccessServiceGetDeveloperApiSettingsPath,
zAccessServiceGetDeveloperApiSettingsResponse,
zAccessServiceListApiKeysPath,
zAccessServiceListApiKeysResponse,
zAccessServiceUpdateAccessChannelsBody,
zAccessServiceUpdateAccessChannelsPath,
zAccessServiceUpdateAccessChannelsResponse,
zAccessServiceUpdateAccessPolicyBody,
zAccessServiceUpdateAccessPolicyPath,
zAccessServiceUpdateAccessPolicyResponse,
zAccessSubjectServiceListAccessSubjectsQuery,
zAccessSubjectServiceListAccessSubjectsResponse,
zAppInstanceServiceCreateAppInstanceBody,
zAppInstanceServiceCreateAppInstanceResponse,
zAppInstanceServiceDeleteAppInstancePath,
zAppInstanceServiceDeleteAppInstanceResponse,
zAppInstanceServiceGetAppInstanceOverviewPath,
zAppInstanceServiceGetAppInstanceOverviewResponse,
zAppInstanceServiceGetAppInstancePath,
zAppInstanceServiceGetAppInstanceResponse,
zAppInstanceServiceListAppInstancesQuery,
zAppInstanceServiceListAppInstancesResponse,
zAppInstanceServiceListAppInstanceSummariesQuery,
zAppInstanceServiceListAppInstanceSummariesResponse,
zAppInstanceServiceUpdateAppInstanceBody,
zAppInstanceServiceUpdateAppInstancePath,
zAppInstanceServiceUpdateAppInstanceResponse,
zConsoleSsoOAuth2LoginResponse,
zConsoleSsoOidcLoginResponse,
zConsoleSsoSamlLoginResponse,
zDeploymentServiceCancelDeploymentBody,
zDeploymentServiceCancelDeploymentPath,
zDeploymentServiceCancelDeploymentResponse,
zDeploymentServiceDeployBody,
zDeploymentServiceDeployResponse,
zDeploymentServiceListDeploymentsPath,
zDeploymentServiceListDeploymentsQuery,
zDeploymentServiceListDeploymentsResponse,
zDeploymentServiceListEnvironmentDeploymentsPath,
zDeploymentServiceListEnvironmentDeploymentsResponse,
zDeploymentServiceListRollbackTargetsPath,
zDeploymentServiceListRollbackTargetsQuery,
zDeploymentServiceListRollbackTargetsResponse,
zDeploymentServicePromoteBody,
zDeploymentServicePromotePath,
zDeploymentServicePromoteResponse,
zDeploymentServiceRollbackBody,
zDeploymentServiceRollbackPath,
zDeploymentServiceRollbackResponse,
zDeploymentServiceUndeployBody,
zDeploymentServiceUndeployPath,
zDeploymentServiceUndeployResponse,
zEnvironmentServiceListEnvironmentsQuery,
zEnvironmentServiceListEnvironmentsResponse,
zReleaseServiceComputeDeploymentOptionsBody,
zReleaseServiceComputeDeploymentOptionsResponse,
zReleaseServiceComputeReleaseDeploymentViewPath,
zReleaseServiceComputeReleaseDeploymentViewQuery,
zReleaseServiceComputeReleaseDeploymentViewResponse,
zReleaseServiceCreateReleaseBody,
zReleaseServiceCreateReleaseResponse,
zReleaseServiceDeleteReleasePath,
zReleaseServiceDeleteReleaseResponse,
zReleaseServiceExportReleaseDslPath,
zReleaseServiceExportReleaseDslResponse,
zReleaseServiceGetReleasePath,
zReleaseServiceGetReleaseResponse,
zReleaseServiceListReleaseCredentialCandidatesPath,
zReleaseServiceListReleaseCredentialCandidatesResponse,
zReleaseServiceListReleasesPath,
zReleaseServiceListReleasesQuery,
zReleaseServiceListReleasesResponse,
zReleaseServiceListReleaseSummariesPath,
zReleaseServiceListReleaseSummariesQuery,
zReleaseServiceListReleaseSummariesResponse,
zReleaseServicePrecheckReleaseBody,
zReleaseServicePrecheckReleaseResponse,
zReleaseServiceUpdateReleaseBody,
zReleaseServiceUpdateReleasePath,
zReleaseServiceUpdateReleaseResponse,
zWebAppAuthGetGroupSubjectsQuery,
zWebAppAuthGetGroupSubjectsResponse,
zWebAppAuthGetWebAppAccessModeQuery,
@ -21,6 +109,524 @@ import {
zWebAppAuthUpdateWebAppWhitelistSubjectsResponse,
} from './zod.gen'
export const listAccessSubjects = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'AccessSubjectService_ListAccessSubjects',
path: '/enterprise/access-subjects',
tags: ['AccessSubjectService'],
})
.input(z.object({ query: zAccessSubjectServiceListAccessSubjectsQuery.optional() }))
.output(zAccessSubjectServiceListAccessSubjectsResponse)
export const accessSubjectService = {
listAccessSubjects,
}
export const listAppInstanceSummaries = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'AppInstanceService_ListAppInstanceSummaries',
path: '/enterprise/app-deploy/appInstanceSummaries',
tags: ['AppInstanceService'],
})
.input(z.object({ query: zAppInstanceServiceListAppInstanceSummariesQuery.optional() }))
.output(zAppInstanceServiceListAppInstanceSummariesResponse)
export const listAppInstances = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'AppInstanceService_ListAppInstances',
path: '/enterprise/app-deploy/appInstances',
tags: ['AppInstanceService'],
})
.input(z.object({ query: zAppInstanceServiceListAppInstancesQuery.optional() }))
.output(zAppInstanceServiceListAppInstancesResponse)
export const createAppInstance = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'AppInstanceService_CreateAppInstance',
path: '/enterprise/app-deploy/appInstances',
tags: ['AppInstanceService'],
})
.input(z.object({ body: zAppInstanceServiceCreateAppInstanceBody }))
.output(zAppInstanceServiceCreateAppInstanceResponse)
export const deleteAppInstance = oc
.route({
inputStructure: 'detailed',
method: 'DELETE',
operationId: 'AppInstanceService_DeleteAppInstance',
path: '/enterprise/app-deploy/appInstances/{appInstanceId}',
tags: ['AppInstanceService'],
})
.input(z.object({ params: zAppInstanceServiceDeleteAppInstancePath }))
.output(zAppInstanceServiceDeleteAppInstanceResponse)
export const getAppInstance = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'AppInstanceService_GetAppInstance',
path: '/enterprise/app-deploy/appInstances/{appInstanceId}',
tags: ['AppInstanceService'],
})
.input(z.object({ params: zAppInstanceServiceGetAppInstancePath }))
.output(zAppInstanceServiceGetAppInstanceResponse)
export const updateAppInstance = oc
.route({
inputStructure: 'detailed',
method: 'PATCH',
operationId: 'AppInstanceService_UpdateAppInstance',
path: '/enterprise/app-deploy/appInstances/{appInstanceId}',
tags: ['AppInstanceService'],
})
.input(
z.object({
body: zAppInstanceServiceUpdateAppInstanceBody,
params: zAppInstanceServiceUpdateAppInstancePath,
}),
)
.output(zAppInstanceServiceUpdateAppInstanceResponse)
export const getAppInstanceOverview = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'AppInstanceService_GetAppInstanceOverview',
path: '/enterprise/app-deploy/appInstances/{appInstanceId}:getOverview',
tags: ['AppInstanceService'],
})
.input(z.object({ params: zAppInstanceServiceGetAppInstanceOverviewPath }))
.output(zAppInstanceServiceGetAppInstanceOverviewResponse)
export const appInstanceService = {
listAppInstanceSummaries,
listAppInstances,
createAppInstance,
deleteAppInstance,
getAppInstance,
updateAppInstance,
getAppInstanceOverview,
}
export const getAccessChannels = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'AccessService_GetAccessChannels',
path: '/enterprise/app-deploy/appInstances/{appInstanceId}/accessChannels',
tags: ['AccessService'],
})
.input(z.object({ params: zAccessServiceGetAccessChannelsPath }))
.output(zAccessServiceGetAccessChannelsResponse)
export const updateAccessChannels = oc
.route({
inputStructure: 'detailed',
method: 'PUT',
operationId: 'AccessService_UpdateAccessChannels',
path: '/enterprise/app-deploy/appInstances/{appInstanceId}/accessChannels',
tags: ['AccessService'],
})
.input(
z.object({
body: zAccessServiceUpdateAccessChannelsBody,
params: zAccessServiceUpdateAccessChannelsPath,
}),
)
.output(zAccessServiceUpdateAccessChannelsResponse)
export const getAccessSettings = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'AccessService_GetAccessSettings',
path: '/enterprise/app-deploy/appInstances/{appInstanceId}/accessSettings',
tags: ['AccessService'],
})
.input(z.object({ params: zAccessServiceGetAccessSettingsPath }))
.output(zAccessServiceGetAccessSettingsResponse)
export const getDeveloperApiSettings = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'AccessService_GetDeveloperApiSettings',
path: '/enterprise/app-deploy/appInstances/{appInstanceId}/developerApiSettings',
tags: ['AccessService'],
})
.input(z.object({ params: zAccessServiceGetDeveloperApiSettingsPath }))
.output(zAccessServiceGetDeveloperApiSettingsResponse)
export const getAccessPolicy = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'AccessService_GetAccessPolicy',
path: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}/accessPolicy',
tags: ['AccessService'],
})
.input(z.object({ params: zAccessServiceGetAccessPolicyPath }))
.output(zAccessServiceGetAccessPolicyResponse)
export const updateAccessPolicy = oc
.route({
inputStructure: 'detailed',
method: 'PUT',
operationId: 'AccessService_UpdateAccessPolicy',
path: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}/accessPolicy',
tags: ['AccessService'],
})
.input(
z.object({
body: zAccessServiceUpdateAccessPolicyBody,
params: zAccessServiceUpdateAccessPolicyPath,
}),
)
.output(zAccessServiceUpdateAccessPolicyResponse)
export const listApiKeys = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'AccessService_ListApiKeys',
path: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}/apiKeys',
tags: ['AccessService'],
})
.input(z.object({ params: zAccessServiceListApiKeysPath }))
.output(zAccessServiceListApiKeysResponse)
export const createApiKey = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'AccessService_CreateApiKey',
path: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}/apiKeys',
tags: ['AccessService'],
})
.input(z.object({ body: zAccessServiceCreateApiKeyBody, params: zAccessServiceCreateApiKeyPath }))
.output(zAccessServiceCreateApiKeyResponse)
export const deleteApiKey = oc
.route({
inputStructure: 'detailed',
method: 'DELETE',
operationId: 'AccessService_DeleteApiKey',
path: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}/apiKeys/{apiKeyId}',
tags: ['AccessService'],
})
.input(z.object({ params: zAccessServiceDeleteApiKeyPath }))
.output(zAccessServiceDeleteApiKeyResponse)
export const accessService = {
getAccessChannels,
updateAccessChannels,
getAccessSettings,
getDeveloperApiSettings,
getAccessPolicy,
updateAccessPolicy,
listApiKeys,
createApiKey,
deleteApiKey,
}
export const listDeployments = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'DeploymentService_ListDeployments',
path: '/enterprise/app-deploy/appInstances/{appInstanceId}/deployments',
tags: ['DeploymentService'],
})
.input(
z.object({
params: zDeploymentServiceListDeploymentsPath,
query: zDeploymentServiceListDeploymentsQuery.optional(),
}),
)
.output(zDeploymentServiceListDeploymentsResponse)
export const listEnvironmentDeployments = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'DeploymentService_ListEnvironmentDeployments',
path: '/enterprise/app-deploy/appInstances/{appInstanceId}/environmentDeployments',
tags: ['DeploymentService'],
})
.input(z.object({ params: zDeploymentServiceListEnvironmentDeploymentsPath }))
.output(zDeploymentServiceListEnvironmentDeploymentsResponse)
export const listRollbackTargets = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'DeploymentService_ListRollbackTargets',
path: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}/rollbackTargets',
tags: ['DeploymentService'],
})
.input(
z.object({
params: zDeploymentServiceListRollbackTargetsPath,
query: zDeploymentServiceListRollbackTargetsQuery.optional(),
}),
)
.output(zDeploymentServiceListRollbackTargetsResponse)
/**
* CancelDeployment cancels the in-flight deployment on the environment.
*/
export const cancelDeployment = oc
.route({
description: 'CancelDeployment cancels the in-flight deployment on the environment.',
inputStructure: 'detailed',
method: 'POST',
operationId: 'DeploymentService_CancelDeployment',
path: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}:cancelDeployment',
tags: ['DeploymentService'],
})
.input(
z.object({
body: zDeploymentServiceCancelDeploymentBody,
params: zDeploymentServiceCancelDeploymentPath,
}),
)
.output(zDeploymentServiceCancelDeploymentResponse)
export const promote = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'DeploymentService_Promote',
path: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}:promote',
tags: ['DeploymentService'],
})
.input(z.object({ body: zDeploymentServicePromoteBody, params: zDeploymentServicePromotePath }))
.output(zDeploymentServicePromoteResponse)
export const rollback = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'DeploymentService_Rollback',
path: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}:rollback',
tags: ['DeploymentService'],
})
.input(z.object({ body: zDeploymentServiceRollbackBody, params: zDeploymentServiceRollbackPath }))
.output(zDeploymentServiceRollbackResponse)
export const undeploy = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'DeploymentService_Undeploy',
path: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}:undeploy',
tags: ['DeploymentService'],
})
.input(z.object({ body: zDeploymentServiceUndeployBody, params: zDeploymentServiceUndeployPath }))
.output(zDeploymentServiceUndeployResponse)
export const deploy = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'DeploymentService_Deploy',
path: '/enterprise/app-deploy/appInstances:deploy',
tags: ['DeploymentService'],
})
.input(z.object({ body: zDeploymentServiceDeployBody }))
.output(zDeploymentServiceDeployResponse)
export const deploymentService = {
listDeployments,
listEnvironmentDeployments,
listRollbackTargets,
cancelDeployment,
promote,
rollback,
undeploy,
deploy,
}
export const listReleaseSummaries = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'ReleaseService_ListReleaseSummaries',
path: '/enterprise/app-deploy/appInstances/{appInstanceId}/releaseSummaries',
tags: ['ReleaseService'],
})
.input(
z.object({
params: zReleaseServiceListReleaseSummariesPath,
query: zReleaseServiceListReleaseSummariesQuery.optional(),
}),
)
.output(zReleaseServiceListReleaseSummariesResponse)
export const listReleases = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'ReleaseService_ListReleases',
path: '/enterprise/app-deploy/appInstances/{appInstanceId}/releases',
tags: ['ReleaseService'],
})
.input(
z.object({
params: zReleaseServiceListReleasesPath,
query: zReleaseServiceListReleasesQuery.optional(),
}),
)
.output(zReleaseServiceListReleasesResponse)
export const computeReleaseDeploymentView = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'ReleaseService_ComputeReleaseDeploymentView',
path: '/enterprise/app-deploy/appInstances/{appInstanceId}:computeReleaseDeploymentView',
tags: ['ReleaseService'],
})
.input(
z.object({
params: zReleaseServiceComputeReleaseDeploymentViewPath,
query: zReleaseServiceComputeReleaseDeploymentViewQuery.optional(),
}),
)
.output(zReleaseServiceComputeReleaseDeploymentViewResponse)
export const createRelease = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'ReleaseService_CreateRelease',
path: '/enterprise/app-deploy/releases',
tags: ['ReleaseService'],
})
.input(z.object({ body: zReleaseServiceCreateReleaseBody }))
.output(zReleaseServiceCreateReleaseResponse)
export const deleteRelease = oc
.route({
inputStructure: 'detailed',
method: 'DELETE',
operationId: 'ReleaseService_DeleteRelease',
path: '/enterprise/app-deploy/releases/{releaseId}',
tags: ['ReleaseService'],
})
.input(z.object({ params: zReleaseServiceDeleteReleasePath }))
.output(zReleaseServiceDeleteReleaseResponse)
export const getRelease = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'ReleaseService_GetRelease',
path: '/enterprise/app-deploy/releases/{releaseId}',
tags: ['ReleaseService'],
})
.input(z.object({ params: zReleaseServiceGetReleasePath }))
.output(zReleaseServiceGetReleaseResponse)
export const updateRelease = oc
.route({
inputStructure: 'detailed',
method: 'PATCH',
operationId: 'ReleaseService_UpdateRelease',
path: '/enterprise/app-deploy/releases/{releaseId}',
tags: ['ReleaseService'],
})
.input(
z.object({ body: zReleaseServiceUpdateReleaseBody, params: zReleaseServiceUpdateReleasePath }),
)
.output(zReleaseServiceUpdateReleaseResponse)
export const exportReleaseDsl = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'ReleaseService_ExportReleaseDSL',
path: '/enterprise/app-deploy/releases/{releaseId}:exportDsl',
tags: ['ReleaseService'],
})
.input(z.object({ params: zReleaseServiceExportReleaseDslPath }))
.output(zReleaseServiceExportReleaseDslResponse)
export const listReleaseCredentialCandidates = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'ReleaseService_ListReleaseCredentialCandidates',
path: '/enterprise/app-deploy/releases/{releaseId}:listCredentialCandidates',
tags: ['ReleaseService'],
})
.input(z.object({ params: zReleaseServiceListReleaseCredentialCandidatesPath }))
.output(zReleaseServiceListReleaseCredentialCandidatesResponse)
export const computeDeploymentOptions = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'ReleaseService_ComputeDeploymentOptions',
path: '/enterprise/app-deploy/releases:computeDeploymentOptions',
tags: ['ReleaseService'],
})
.input(z.object({ body: zReleaseServiceComputeDeploymentOptionsBody }))
.output(zReleaseServiceComputeDeploymentOptionsResponse)
export const precheckRelease = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'ReleaseService_PrecheckRelease',
path: '/enterprise/app-deploy/releases:precheck',
tags: ['ReleaseService'],
})
.input(z.object({ body: zReleaseServicePrecheckReleaseBody }))
.output(zReleaseServicePrecheckReleaseResponse)
export const releaseService = {
listReleaseSummaries,
listReleases,
computeReleaseDeploymentView,
createRelease,
deleteRelease,
getRelease,
updateRelease,
exportReleaseDsl,
listReleaseCredentialCandidates,
computeDeploymentOptions,
precheckRelease,
}
/**
* ListEnvironments returns only the environments the current user can
* deploy to.
*/
export const listEnvironments = oc
.route({
description: 'ListEnvironments returns only the environments the current user can\n deploy to.',
inputStructure: 'detailed',
method: 'GET',
operationId: 'EnvironmentService_ListEnvironments',
path: '/enterprise/app-deploy/environments',
tags: ['EnvironmentService'],
})
.input(z.object({ query: zEnvironmentServiceListEnvironmentsQuery.optional() }))
.output(zEnvironmentServiceListEnvironmentsResponse)
export const environmentService = {
listEnvironments,
}
export const oAuth2Login = oc
.route({
inputStructure: 'detailed',
@ -133,6 +739,12 @@ export const webAppAuth = {
}
export const contract = {
accessSubjectService,
appInstanceService,
accessService,
deploymentService,
releaseService,
environmentService,
consoleSso,
webAppAuth,
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -7,9 +7,36 @@ import yaml from 'js-yaml'
type JsonObject = Record<string, unknown>
type OpenApiDocument = JsonObject & {
components?: OpenApiComponents
paths?: Record<string, unknown>
}
type OpenApiComponents = JsonObject & {
schemas?: Record<string, OpenApiSchema>
}
type OpenApiMediaType = JsonObject & {
schema?: unknown
}
type OpenApiOperation = JsonObject & {
operationId?: string
responses?: Record<string, OpenApiResponse>
}
type OpenApiPathItem = Record<string, unknown>
type OpenApiResponse = JsonObject & {
content?: Record<string, OpenApiMediaType>
}
type OpenApiSchema = JsonObject & {
enum?: unknown[]
format?: string
properties?: Record<string, OpenApiSchema>
type?: string | string[]
}
type ContractOperation = {
id: string
operationId?: string
@ -21,9 +48,30 @@ const enterpriseServerDir = process.env.DIFY_ENTERPRISE_SERVER
? path.resolve(process.env.DIFY_ENTERPRISE_SERVER)
: path.resolve(currentDir, '../../../dify-enterprise/server')
const enterpriseOpenApiPath = path.join(enterpriseServerDir, 'pkg/apis/enterprise/openapi.yaml')
const operationMethods = new Set(['delete', 'get', 'patch', 'post', 'put'])
const isConsoleApiPath = (routePath: string) => routePath.startsWith('/console/api/')
const isObject = (value: unknown): value is JsonObject => {
return !!value && typeof value === 'object' && !Array.isArray(value)
}
const isOpenApiSchema = (value: unknown): value is OpenApiSchema => {
return isObject(value)
}
const asOpenApiOperation = (value: unknown): OpenApiOperation | undefined => {
return isObject(value) ? value as OpenApiOperation : undefined
}
const asOpenApiResponse = (value: unknown): OpenApiResponse | undefined => {
return isObject(value) ? value as OpenApiResponse : undefined
}
const asOpenApiMediaType = (value: unknown): OpenApiMediaType | undefined => {
return isObject(value) ? value as OpenApiMediaType : undefined
}
const stripConsoleApiPrefix = (routePath: string) => {
if (isConsoleApiPath(routePath))
return routePath.replace('/console/api', '')
@ -34,9 +82,18 @@ const stripConsoleApiPrefix = (routePath: string) => {
const stripSchemaNamePrefix = (schemaName: string) => {
return schemaName
.replace(/^dify\.enterprise\.api\.enterprise\./, '')
.replace(/^dify\.enterprise\.api\.appdeploy\.v1\./, '')
.replace(/^dify\.enterprise\.api\.appdeploy\./, '')
.replace(/^pagination\./, '')
}
const contractTagSegment = (tag?: string) => {
if (tag === 'EnterpriseAppDeployConsole')
return 'AppDeploy'
return tag || 'default'
}
const contractNameSegments = (operation: ContractOperation) => {
const operationId = operation.operationId || operation.id
const tag = operation.tags?.[0]
@ -48,7 +105,165 @@ const contractNameSegments = (operation: ContractOperation) => {
}
const contractPathSegments = (operation: ContractOperation) => {
return [operation.tags?.[0] || 'default', ...contractNameSegments(operation)]
return [contractTagSegment(operation.tags?.[0]), ...contractNameSegments(operation)]
}
const hasSchemaLessResponseContent = (operation: OpenApiOperation) => {
if (!isObject(operation.responses))
return false
return Object.values(operation.responses).some((response) => {
const openApiResponse = asOpenApiResponse(response)
if (!openApiResponse || !isObject(openApiResponse.content))
return false
return Object.values(openApiResponse.content).some((mediaType) => {
const openApiMediaType = asOpenApiMediaType(mediaType)
return !!openApiMediaType && !('schema' in openApiMediaType)
})
})
}
// protoc-gen-openapi emits google.api.HttpBody responses as `*/*: {}`. Skip these
// raw download operations until the source OpenAPI exposes an explicit schema.
const stripSchemaLessResponseOperations = (pathItem: OpenApiPathItem) => {
return Object.fromEntries(
Object.entries(pathItem).filter(([method, operation]) => {
if (!operationMethods.has(method.toLowerCase()))
return true
const openApiOperation = asOpenApiOperation(operation)
return !openApiOperation || !hasSchemaLessResponseContent(openApiOperation)
}),
)
}
const toWords = (value: string) => {
return value
.replace(/[{}]/g, '')
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.split(/[^a-z0-9]+/i)
.filter(Boolean)
}
const toPascalCase = (words: string[]) => {
return words.map(word => `${word.charAt(0).toUpperCase()}${word.slice(1)}`).join('')
}
const commonWordPrefix = (values: string[]) => {
const wordLists = values.map(value => value.split('_'))
const firstWords = wordLists[0] ?? []
const prefix: string[] = []
for (const [index, word] of firstWords.entries()) {
if (!wordLists.every(words => words[index] === word))
break
prefix.push(word)
}
return prefix
}
const enumSchemaNameFromValues = (values: unknown[]) => {
if (values.length === 0 || !values.every(value => typeof value === 'string'))
return undefined
const prefix = commonWordPrefix(values)
if (prefix.length < 2)
return undefined
return toPascalCase(prefix.map(word => word.toLowerCase()))
}
const findSchemaEntry = (
schemas: Record<string, OpenApiSchema>,
schemaName: string,
): [string, OpenApiSchema] | undefined => {
return Object.entries(schemas)
.find(([name]) => stripSchemaNamePrefix(name) === schemaName)
}
const enumValuesKey = (values: unknown[]) => JSON.stringify(values)
const reusableEnumSchema = (propertySchema: OpenApiSchema): OpenApiSchema => ({
...(propertySchema.format ? { format: propertySchema.format } : {}),
enum: propertySchema.enum,
type: propertySchema.type ?? 'string',
})
const enumSchemaKey = (
schemas: Record<string, OpenApiSchema>,
preferredName: string,
valuesKey: string,
valuesToSchemaKey: Map<string, string>,
schemaName: string,
propertyName: string,
) => {
const existingKey = valuesToSchemaKey.get(valuesKey)
if (existingKey)
return existingKey
const existingEnumEntry = findSchemaEntry(schemas, preferredName)
if (!existingEnumEntry)
return preferredName
const existingEnumValues = existingEnumEntry[1].enum
if (Array.isArray(existingEnumValues) && enumValuesKey(existingEnumValues) === valuesKey)
return existingEnumEntry[0]
return `${stripSchemaNamePrefix(schemaName)}${toPascalCase(toWords(propertyName))}`
}
const promoteInlineEnumSchema = (
schemas: Record<string, OpenApiSchema>,
schemaName: string,
properties: Record<string, OpenApiSchema>,
propertyName: string,
propertySchema: OpenApiSchema,
valuesToSchemaKey: Map<string, string>,
) => {
if (!Array.isArray(propertySchema.enum))
return
const preferredName = enumSchemaNameFromValues(propertySchema.enum)
if (!preferredName)
return
const valuesKey = enumValuesKey(propertySchema.enum)
const key = enumSchemaKey(schemas, preferredName, valuesKey, valuesToSchemaKey, schemaName, propertyName)
if (!schemas[key])
schemas[key] = reusableEnumSchema(propertySchema)
valuesToSchemaKey.set(valuesKey, key)
properties[propertyName] = {
$ref: `#/components/schemas/${key}`,
}
}
// gnostic's protoc-gen-openapi inlines proto enum schemas into every field.
// Promote prefixable inline enums to reusable schemas so Hey API can emit
// runtime enum objects from the generated contract.
const promoteReusableEnumSchemasForHeyApi = (document: OpenApiDocument) => {
const schemas = document.components?.schemas
if (!schemas)
return
const valuesToSchemaKey = new Map<string, string>()
Object.entries(schemas).forEach(([schemaName, schema]) => {
const properties = schema.properties
if (!properties)
return
Object.entries(properties).forEach(([propertyName, propertySchema]) => {
if (!isOpenApiSchema(propertySchema))
return
promoteInlineEnumSchema(schemas, schemaName, properties, propertyName, propertySchema, valuesToSchemaKey)
})
})
}
const normalizeEnterpriseOpenApi = () => {
@ -63,9 +278,17 @@ const normalizeEnterpriseOpenApi = () => {
document.paths = Object.fromEntries(
Object.entries(paths)
.filter(([routePath]) => isConsoleApiPath(routePath))
.map(([routePath, pathItem]) => [stripConsoleApiPrefix(routePath), pathItem]),
.map(([routePath, pathItem]) => {
if (!isObject(pathItem))
return [stripConsoleApiPrefix(routePath), pathItem]
return [stripConsoleApiPrefix(routePath), stripSchemaLessResponseOperations(pathItem)]
})
.filter(([, pathItem]) => !isObject(pathItem) || Object.keys(pathItem).length > 0),
)
promoteReusableEnumSchemasForHeyApi(document)
return document
}
@ -97,6 +320,9 @@ export default defineConfig({
{
name: '@hey-api/typescript',
comments: false,
enums: {
mode: 'javascript',
},
},
'zod',
{

54
pnpm-lock.yaml generated
View File

@ -168,6 +168,9 @@ catalogs:
'@tanstack/eslint-plugin-query':
specifier: 5.101.0
version: 5.101.0
'@tanstack/query-core':
specifier: 5.101.0
version: 5.101.0
'@tanstack/react-form':
specifier: 1.33.0
version: 1.33.0
@ -393,6 +396,12 @@ catalogs:
jotai:
specifier: 2.20.1
version: 2.20.1
jotai-scope:
specifier: 0.11.0
version: 0.11.0
jotai-tanstack-query:
specifier: 0.11.0
version: 0.11.0
js-audio-recorder:
specifier: 1.0.7
version: 1.0.7
@ -1096,6 +1105,9 @@ importers:
'@tailwindcss/typography':
specifier: 'catalog:'
version: 0.5.20(tailwindcss@4.3.1)
'@tanstack/query-core':
specifier: 'catalog:'
version: 5.101.0
'@tanstack/react-form':
specifier: 'catalog:'
version: 1.33.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@ -1189,6 +1201,12 @@ importers:
jotai:
specifier: 'catalog:'
version: 2.20.1(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.2.17)(react@19.2.7)
jotai-scope:
specifier: 'catalog:'
version: 0.11.0(jotai@2.20.1(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)
jotai-tanstack-query:
specifier: 'catalog:'
version: 0.11.0(@tanstack/query-core@5.101.0)(@tanstack/react-query@5.101.0(react@19.2.7))(jotai@2.20.1(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)
js-audio-recorder:
specifier: 'catalog:'
version: 1.0.7
@ -7198,6 +7216,26 @@ packages:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true
jotai-scope@0.11.0:
resolution: {integrity: sha512-ofiW0Z0i3lTw509Gx0+T6fqsDPMDxMn+AHmNs9iF9OA8CmK1/0xRprPxuZ89UZdBzt6jcrTYdunNZSF2255zJQ==}
engines: {node: '>=14.0.0'}
peerDependencies:
jotai: '>=2.20.0'
react: '>=16.0.0'
jotai-tanstack-query@0.11.0:
resolution: {integrity: sha512-Ys0u0IuuS6/okUJOulFTdCVfVaeKbm1+lKVSN9zHhIxtrAXl9FM4yu7fNvxM6fSz/NCE9tZOKR0MQ3hvplaH8A==}
peerDependencies:
'@tanstack/query-core': '*'
'@tanstack/react-query': '*'
jotai: '>=2.0.0'
react: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@tanstack/react-query':
optional: true
react:
optional: true
jotai@2.20.1:
resolution: {integrity: sha512-dnuKfU/GLi8B28RRMjQ3AfoN7kfzP8o41+AX2FmITZqEMY8PHnjABq+VkEooomLwYaGjda+pgy0yFSjaHX/ZPg==}
engines: {node: '>=12.20.0'}
@ -16182,6 +16220,19 @@ snapshots:
jiti@2.7.0: {}
jotai-scope@0.11.0(jotai@2.20.1(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.2.17)(react@19.2.7))(react@19.2.7):
dependencies:
jotai: 2.20.1(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.2.17)(react@19.2.7)
react: 19.2.7
jotai-tanstack-query@0.11.0(@tanstack/query-core@5.101.0)(@tanstack/react-query@5.101.0(react@19.2.7))(jotai@2.20.1(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.2.17)(react@19.2.7))(react@19.2.7):
dependencies:
'@tanstack/query-core': 5.101.0
jotai: 2.20.1(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.2.17)(react@19.2.7)
optionalDependencies:
'@tanstack/react-query': 5.101.0(react@19.2.7)
react: 19.2.7
jotai@2.20.1(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.2.17)(react@19.2.7):
optionalDependencies:
'@babel/core': 7.29.7
@ -19321,6 +19372,7 @@ time:
'@tailwindcss/typography@0.5.20': '2026-06-08T10:34:41.124Z'
'@tailwindcss/vite@4.3.1': '2026-06-12T17:59:45.667Z'
'@tanstack/eslint-plugin-query@5.101.0': '2026-06-02T19:24:31.866Z'
'@tanstack/query-core@5.101.0': '2026-06-02T19:24:32.202Z'
'@tanstack/react-form@1.33.0': '2026-05-28T17:05:42.660Z'
'@tanstack/react-hotkeys@0.10.0': '2026-04-25T12:28:06.989Z'
'@tanstack/react-query@5.101.0': '2026-06-02T19:24:39.383Z'
@ -19397,6 +19449,8 @@ time:
i18next@26.3.1: '2026-06-03T14:14:17.016Z'
iconify-import-svg@0.2.0: '2026-04-20T06:18:25.132Z'
immer@11.1.8: '2026-05-08T15:09:33.021Z'
jotai-scope@0.11.0: '2026-05-13T18:43:15.331Z'
jotai-tanstack-query@0.11.0: '2025-08-01T02:55:49.826Z'
jotai@2.20.1: '2026-06-11T06:30:45.782Z'
js-audio-recorder@1.0.7: '2021-01-09T10:20:49.923Z'
js-cookie@3.0.8: '2026-05-29T10:51:39.065Z'

View File

@ -106,6 +106,7 @@ catalog:
'@tailwindcss/typography': 0.5.20
'@tailwindcss/vite': 4.3.1
'@tanstack/eslint-plugin-query': 5.101.0
'@tanstack/query-core': 5.101.0
'@tanstack/react-form': 1.33.0
'@tanstack/react-hotkeys': 0.10.0
'@tanstack/react-query': 5.101.0
@ -181,6 +182,8 @@ catalog:
iconify-import-svg: 0.2.0
immer: 11.1.8
jotai: 2.20.1
jotai-scope: 0.11.0
jotai-tanstack-query: 0.11.0
js-audio-recorder: 1.0.7
js-cookie: 3.0.8
js-yaml: 4.2.0

View File

@ -1,7 +1,7 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import AppPublisher from '@/app/components/app/app-publisher'
import { AppPublisher } from '@/app/components/app/app-publisher'
import { AccessMode } from '@/models/access-control'
import { AppModeEnum } from '@/types/app'
@ -89,7 +89,7 @@ vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', ()
}))
vi.mock('@/app/components/app/app-access-control', () => ({
default: ({
AccessControl: ({
onConfirm,
onClose,
}: {

View File

@ -1,12 +1,11 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import AppPublisher from '@/app/components/app/app-publisher'
import { AppPublisher } from '@/app/components/app/app-publisher'
import { AccessMode } from '@/models/access-control'
import { AppModeEnum } from '@/types/app'
const mockTrackEvent = vi.fn()
const mockRefetch = vi.fn()
const mockFetchInstalledAppList = vi.fn()
const mockFetchAppDetailDirect = vi.fn()
const mockToastError = vi.fn()
@ -64,7 +63,6 @@ vi.mock('@/service/access-control', () => ({
useGetUserCanAccessApp: () => ({
data: { result: true },
isLoading: false,
refetch: mockRefetch,
}),
useAppWhiteListSubjects: () => ({
data: { groups: [], members: [] },
@ -115,7 +113,7 @@ vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', ()
}))
vi.mock('@/app/components/app/app-access-control', () => ({
default: () => <div data-testid="app-access-control" />,
AccessControl: () => <div data-testid="app-access-control" />,
}))
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
@ -182,8 +180,6 @@ describe('App Publisher Flow', () => {
app_name: 'Demo App',
}))
})
expect(mockRefetch).toHaveBeenCalled()
})
it('opens embedded modal and resolves the installed explore target', async () => {

View File

@ -13,7 +13,7 @@ import type { App } from '@/types/app'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import AppCard from '@/app/components/apps/app-card'
import { AppCard } from '@/app/components/apps/app-card'
import { AccessMode } from '@/models/access-control'
import { exportAppConfig, updateAppInfo } from '@/service/apps'
import { AppModeEnum } from '@/types/app'
@ -64,10 +64,10 @@ vi.mock('@tanstack/react-query', async (importOriginal) => {
})
vi.mock('@/next/dynamic', () => ({
default: (loader: () => Promise<{ default: React.ComponentType }>) => {
default: (loader: () => Promise<React.ComponentType | { default: React.ComponentType }>) => {
let Component: React.ComponentType<Record<string, unknown>> | null = null
loader().then((mod) => {
Component = mod.default as React.ComponentType<Record<string, unknown>>
Component = (typeof mod === 'function' ? mod : mod.default) as React.ComponentType<Record<string, unknown>>
}).catch(() => {})
const Wrapper = (props: Record<string, unknown>) => {
if (Component)
@ -201,7 +201,7 @@ vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({
}))
vi.mock('@/app/components/app/app-access-control', () => ({
default: ({ onConfirm, onClose }: Record<string, unknown>) => (
AccessControl: ({ onConfirm, onClose }: Record<string, unknown>) => (
<div data-testid="access-control-modal">
<button data-testid="confirm-access" onClick={onConfirm as () => void}>Confirm</button>
<button data-testid="cancel-access" onClick={onClose as () => void}>Cancel</button>

View File

@ -1,13 +1,16 @@
import type { ReactNode } from 'react'
import { useQuery } from '@tanstack/react-query'
import { render, screen } from '@testing-library/react'
import { screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import RoleRouteGuard from '../role-route-guard'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { RoleRouteGuard } from '../role-route-guard'
const mocks = vi.hoisted(() => ({
redirect: vi.fn((url: string) => {
throw new Error(`NEXT_REDIRECT:${url}`)
}),
currentWorkspaceQueryOptions: vi.fn(() => ({ queryKey: ['console', 'workspaces', 'current', 'post'] })),
systemFeaturesQueryKey: vi.fn(() => ['console', 'systemFeatures', 'get']),
}))
let mockPathname = '/apps'
@ -27,6 +30,11 @@ vi.mock('@tanstack/react-query', async (importOriginal) => {
vi.mock('@/service/client', () => ({
consoleQuery: {
systemFeatures: {
get: {
queryKey: mocks.systemFeaturesQueryKey,
},
},
workspaces: {
current: {
post: {
@ -39,6 +47,19 @@ vi.mock('@/service/client', () => ({
const mockUseQuery = vi.mocked(useQuery)
function renderGuard(children: ReactNode) {
return renderWithSystemFeatures(
<RoleRouteGuard>
{children}
</RoleRouteGuard>,
{
systemFeatures: {
enable_app_deploy: true,
},
},
)
}
const setCurrentWorkspaceQuery = (overrides: { role?: string, isPending?: boolean } = {}) => {
mockUseQuery.mockReturnValue({
data: overrides.role,
@ -56,11 +77,7 @@ describe('RoleRouteGuard', () => {
it('should render loading while workspace is loading', () => {
setCurrentWorkspaceQuery({ isPending: true })
render((
<RoleRouteGuard>
<div>content</div>
</RoleRouteGuard>
))
renderGuard(<div>content</div>)
expect(screen.getByRole('status')).toBeInTheDocument()
expect(screen.queryByText('content')).not.toBeInTheDocument()
@ -73,11 +90,16 @@ describe('RoleRouteGuard', () => {
it('should redirect dataset operator on guarded routes', () => {
setCurrentWorkspaceQuery({ role: 'dataset_operator' })
expect(() => render((
<RoleRouteGuard>
<div>content</div>
</RoleRouteGuard>
))).toThrow('NEXT_REDIRECT:/datasets')
expect(() => renderGuard(<div>content</div>)).toThrow('NEXT_REDIRECT:/datasets')
expect(mocks.redirect).toHaveBeenCalledWith('/datasets')
})
it('should redirect dataset operator on deployments routes', () => {
mockPathname = '/deployments/create'
setCurrentWorkspaceQuery({ role: 'dataset_operator' })
expect(() => renderGuard(<div>content</div>)).toThrow('NEXT_REDIRECT:/datasets')
expect(mocks.redirect).toHaveBeenCalledWith('/datasets')
})
@ -86,11 +108,7 @@ describe('RoleRouteGuard', () => {
mockPathname = '/plugins'
setCurrentWorkspaceQuery({ role: 'dataset_operator' })
render((
<RoleRouteGuard>
<div>content</div>
</RoleRouteGuard>
))
renderGuard(<div>content</div>)
expect(screen.getByText('content')).toBeInTheDocument()
expect(mocks.redirect).not.toHaveBeenCalled()
@ -100,11 +118,7 @@ describe('RoleRouteGuard', () => {
mockPathname = '/plugins'
setCurrentWorkspaceQuery({ isPending: true })
render((
<RoleRouteGuard>
<div>content</div>
</RoleRouteGuard>
))
renderGuard(<div>content</div>)
expect(screen.getByText('content')).toBeInTheDocument()
expect(screen.queryByRole('status')).not.toBeInTheDocument()

View File

@ -0,0 +1,8 @@
import { AccessTab } from '@/features/deployments/detail/access-tab'
export default async function InstanceDetailAccessPage({ params }: {
params: Promise<{ appInstanceId: string }>
}) {
const { appInstanceId } = await params
return <AccessTab appInstanceId={appInstanceId} />
}

View File

@ -0,0 +1,8 @@
import { DeveloperApiTab } from '@/features/deployments/detail/developer-api-tab'
export default async function InstanceDetailApiTokensPage({ params }: {
params: Promise<{ appInstanceId: string }>
}) {
const { appInstanceId } = await params
return <DeveloperApiTab appInstanceId={appInstanceId} />
}

View File

@ -0,0 +1,8 @@
import { DeployTab } from '@/features/deployments/detail/deploy-tab'
export default async function InstanceDetailInstancesPage({ params }: {
params: Promise<{ appInstanceId: string }>
}) {
const { appInstanceId } = await params
return <DeployTab appInstanceId={appInstanceId} />
}

View File

@ -0,0 +1,15 @@
import type { ReactNode } from 'react'
import { InstanceDetail } from '@/features/deployments/detail'
export default async function InstanceDetailLayout({ children, params }: {
children: ReactNode
params: Promise<{ appInstanceId: string }>
}) {
const { appInstanceId } = await params
return (
<InstanceDetail appInstanceId={appInstanceId}>
{children}
</InstanceDetail>
)
}

View File

@ -0,0 +1,8 @@
import { OverviewTab } from '@/features/deployments/detail/overview-tab'
export default async function InstanceDetailOverviewPage({ params }: {
params: Promise<{ appInstanceId: string }>
}) {
const { appInstanceId } = await params
return <OverviewTab appInstanceId={appInstanceId} />
}

View File

@ -0,0 +1,8 @@
import { redirect } from '@/next/navigation'
export default async function InstanceDetailPage({ params }: {
params: Promise<{ appInstanceId: string }>
}) {
const { appInstanceId } = await params
redirect(`/deployments/${appInstanceId}/overview`)
}

View File

@ -0,0 +1,8 @@
import { VersionsTab } from '@/features/deployments/detail/versions-tab'
export default async function InstanceDetailReleasesPage({ params }: {
params: Promise<{ appInstanceId: string }>
}) {
const { appInstanceId } = await params
return <VersionsTab appInstanceId={appInstanceId} />
}

View File

@ -0,0 +1,12 @@
'use client'
import { useTranslation } from 'react-i18next'
import { CreateDeploymentGuide } from '@/features/deployments/create-guide'
import useDocumentTitle from '@/hooks/use-document-title'
export default function CreateDeploymentPage() {
const { t } = useTranslation('deployments')
useDocumentTitle(t('documentTitle.create'))
return <CreateDeploymentGuide />
}

View File

@ -0,0 +1,13 @@
import type { ReactNode } from 'react'
import { DeployDrawer } from '@/features/deployments/deploy-drawer'
export default function DeploymentsLayout({ children }: {
children: ReactNode
}) {
return (
<>
{children}
<DeployDrawer />
</>
)
}

View File

@ -0,0 +1,10 @@
'use client'
import { useTranslation } from 'react-i18next'
import { DeploymentsList } from '@/features/deployments/list'
import useDocumentTitle from '@/hooks/use-document-title'
export default function DeploymentsPage() {
const { t } = useTranslation('deployments')
useDocumentTitle(t('documentTitle.list'))
return <DeploymentsList />
}

View File

@ -16,9 +16,9 @@ import { ModalContextProvider } from '@/context/modal-context-provider'
import { ProviderContextProvider } from '@/context/provider-context-provider'
import PartnerStack from '../components/billing/partner-stack'
import { CommonLayoutHydrationBoundary } from './hydration-boundary'
import RoleRouteGuard from './role-route-guard'
import { RoleRouteGuard } from './role-route-guard'
const Layout = async ({ children }: { children: ReactNode }) => {
export default async function Layout({ children }: { children: ReactNode }) {
return (
<>
<GoogleAnalyticsScripts />
@ -49,4 +49,3 @@ const Layout = async ({ children }: { children: ReactNode }) => {
</>
)
}
export default Layout

View File

@ -1,28 +1,38 @@
'use client'
import type { ReactNode } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
import Loading from '@/app/components/base/loading'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { redirect, usePathname } from '@/next/navigation'
import { consoleQuery } from '@/service/client'
const datasetOperatorRedirectRoutes = ['/', '/apps', '/app', '/snippets', '/explore', '/tools', '/integrations'] as const
const datasetOperatorRedirectRoutes = ['/', '/apps', '/app', '/deployments', '/snippets', '/explore', '/tools', '/integrations'] as const
const isPathUnderRoute = (pathname: string, route: string) => pathname === route || pathname.startsWith(`${route}/`)
function isPathUnderRoute(pathname: string, route: string) {
return pathname === route || pathname.startsWith(`${route}/`)
}
export default function RoleRouteGuard({ children }: { children: ReactNode }) {
export function RoleRouteGuard({ children }: { children: ReactNode }) {
const currentWorkspaceRoleQuery = useQuery(consoleQuery.workspaces.current.post.queryOptions({
select: workspace => workspace.role,
}))
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const pathname = usePathname()
const shouldGuardRoute = datasetOperatorRedirectRoutes.some(route => isPathUnderRoute(pathname, route))
const shouldRedirect = shouldGuardRoute && !currentWorkspaceRoleQuery.isPending && currentWorkspaceRoleQuery.data === 'dataset_operator'
const shouldRedirectDatasetOperator = shouldGuardRoute
&& !currentWorkspaceRoleQuery.isPending
&& currentWorkspaceRoleQuery.data === 'dataset_operator'
const shouldRedirectAppDeploy = isPathUnderRoute(pathname, '/deployments') && !systemFeatures.enable_app_deploy
// Block rendering only for guarded routes to avoid permission flicker.
if (shouldGuardRoute && currentWorkspaceRoleQuery.isPending)
return <Loading type="app" />
if (shouldRedirect)
if (shouldRedirectAppDeploy)
redirect('/apps')
if (shouldRedirectDatasetOperator)
redirect('/datasets')
return <>{children}</>

View File

@ -1,5 +1,5 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import AccessControlDialog from '../access-control-dialog'
import { AccessControlDialog } from '../access-control-dialog'
describe('AccessControlDialog', () => {
it('should render dialog content when visible', () => {

View File

@ -1,45 +1,43 @@
import { fireEvent, render, screen } from '@testing-library/react'
import useAccessControlStore from '@/context/access-control-store'
import { AccessMode } from '@/models/access-control'
import AccessControlItem from '../access-control-item'
import { AccessControlItem } from '../access-control-item'
import { AccessControlRadioGroupHarness } from './access-control-radio-group-harness'
import { createAccessControlDraftHarness } from './access-control-test-utils'
describe('AccessControlItem', () => {
beforeEach(() => {
vi.clearAllMocks()
useAccessControlStore.setState({
appId: '',
specificGroups: [],
specificMembers: [],
currentMenu: AccessMode.PUBLIC,
selectedGroupsForBreadcrumb: [],
})
})
it('should update current menu when selecting a different access type', () => {
render(
<AccessControlItem type={AccessMode.ORGANIZATION}>
<span>Organization Only</span>
</AccessControlItem>,
const harness = createAccessControlDraftHarness(
<AccessControlRadioGroupHarness>
<AccessControlItem type={AccessMode.ORGANIZATION}>
<span>Organization Only</span>
</AccessControlItem>
</AccessControlRadioGroupHarness>,
{ currentMenu: AccessMode.PUBLIC },
)
render(harness.element)
const option = screen.getByText('Organization Only').parentElement as HTMLElement
const option = screen.getByRole('radio', { name: 'Organization Only' })
fireEvent.click(option)
expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.ORGANIZATION)
expect(harness.getSnapshot().currentMenu).toBe(AccessMode.ORGANIZATION)
})
it('should keep the selected state for the active access type', () => {
useAccessControlStore.setState({
currentMenu: AccessMode.ORGANIZATION,
})
render(
<AccessControlItem type={AccessMode.ORGANIZATION}>
<span>Organization Only</span>
</AccessControlItem>,
const harness = createAccessControlDraftHarness(
<AccessControlRadioGroupHarness>
<AccessControlItem type={AccessMode.ORGANIZATION}>
<span>Organization Only</span>
</AccessControlItem>
</AccessControlRadioGroupHarness>,
{ currentMenu: AccessMode.ORGANIZATION },
)
render(harness.element)
const option = screen.getByText('Organization Only').parentElement as HTMLElement
expect(option).toHaveClass('border-components-option-card-option-selected-border')
const option = screen.getByRole('radio', { name: 'Organization Only' })
expect(option).toHaveAttribute('data-checked')
})
})

View File

@ -0,0 +1,17 @@
import type { ReactNode } from 'react'
import type { AccessMode } from '@/models/access-control'
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
import { useAccessControlStore } from '../store'
export function AccessControlRadioGroupHarness({ children }: {
children: ReactNode
}) {
const currentMenu = useAccessControlStore(state => state.currentMenu)
const setCurrentMenu = useAccessControlStore(state => state.setCurrentMenu)
return (
<RadioGroup<AccessMode> value={currentMenu} onValueChange={setCurrentMenu}>
{children}
</RadioGroup>
)
}

View File

@ -0,0 +1,71 @@
import type { ReactNode } from 'react'
import type { AccessControlDraft, AccessControlStore } from '../store'
import { createElement } from 'react'
import { AccessMode } from '@/models/access-control'
import { useAccessControlStore } from '../store'
import { AccessControlDraftProvider } from '../store-provider'
const emptyDraft = {
appId: '',
currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS,
specificGroups: [],
specificMembers: [],
selectedGroupsForBreadcrumb: [],
} satisfies Required<AccessControlDraft>
function draftKey(draft: AccessControlDraft) {
return [
draft.appId ?? '',
draft.currentMenu,
draft.specificGroups?.map(group => group.id).join(',') ?? '',
draft.specificMembers?.map(member => member.id).join(',') ?? '',
draft.selectedGroupsForBreadcrumb?.map(group => group.id).join(',') ?? '',
].join(':')
}
function completeDraft(initialDraft: Partial<AccessControlDraft> = {}): Required<AccessControlDraft> {
return {
...emptyDraft,
...initialDraft,
}
}
function SnapshotProbe({ onSnapshot }: {
onSnapshot: (snapshot: AccessControlStore) => void
}) {
onSnapshot(useAccessControlStore(state => state))
return null
}
export function createAccessControlDraftHarness(
children: ReactNode,
initialDraft?: Partial<AccessControlDraft>,
) {
const draft = completeDraft(initialDraft)
let snapshot: AccessControlStore = {
appId: draft.appId,
specificGroups: draft.specificGroups,
setSpecificGroups: () => undefined,
specificMembers: draft.specificMembers,
setSpecificMembers: () => undefined,
currentMenu: draft.currentMenu,
setCurrentMenu: () => undefined,
selectedGroupsForBreadcrumb: draft.selectedGroupsForBreadcrumb,
setSelectedGroupsForBreadcrumb: () => undefined,
}
return {
element: createElement(
AccessControlDraftProvider,
{
draftKey: draftKey(draft),
initialDraft: draft,
},
createElement(SnapshotProbe, {
onSnapshot: nextSnapshot => snapshot = nextSnapshot,
}),
children,
),
getSnapshot: () => snapshot,
}
}

View File

@ -1,24 +1,23 @@
import type { AccessControlAccount, AccessControlGroup, Subject } from '@/models/access-control'
import type { App } from '@/types/app'
import { SubjectType as EnterpriseSubjectType } from '@dify/contracts/enterprise/types.gen'
import { toast } from '@langgenius/dify-ui/toast'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features'
import useAccessControlStore from '@/context/access-control-store'
import { AccessMode, SubjectType } from '@/models/access-control'
import AccessControlDialog from '../access-control-dialog'
import AccessControlItem from '../access-control-item'
import AddMemberOrGroupDialog from '../add-member-or-group-pop'
import AccessControl from '../index'
import SpecificGroupsOrMembers from '../specific-groups-or-members'
import { AccessControlDialog } from '../access-control-dialog'
import { AccessControlItem } from '../access-control-item'
import { AddMemberOrGroupDialog } from '../add-member-or-group-pop'
import { AccessControl } from '../index'
import { SpecificGroupsOrMembers } from '../specific-groups-or-members'
import { AccessControlRadioGroupHarness } from './access-control-radio-group-harness'
import { createAccessControlDraftHarness } from './access-control-test-utils'
const mockUseAppWhiteListSubjects = vi.fn()
const mockUseSearchForWhiteListCandidates = vi.fn()
const mockMutateAsync = vi.fn()
const mockUseUpdateAccessMode = vi.fn(() => ({
isPending: false,
mutateAsync: mockMutateAsync,
}))
const mockMutate = vi.fn()
const mockUseMutation = vi.hoisted(() => vi.fn())
const intersectionObserverMocks = vi.hoisted(() => ({
callback: null as null | ((entries: Array<{ isIntersecting: boolean }>) => void),
}))
@ -39,9 +38,16 @@ vi.mock('@/context/app-context', () => ({
vi.mock('@/service/access-control', () => ({
useAppWhiteListSubjects: (...args: unknown[]) => mockUseAppWhiteListSubjects(...args),
useSearchForWhiteListCandidates: (...args: unknown[]) => mockUseSearchForWhiteListCandidates(...args),
useUpdateAccessMode: () => mockUseUpdateAccessMode(),
}))
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useMutation: (...args: unknown[]) => mockUseMutation(...args),
}
})
vi.mock('ahooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('ahooks')>()
return {
@ -94,10 +100,12 @@ beforeAll(() => {
})
beforeEach(() => {
mockMutateAsync.mockResolvedValue(undefined)
mockUseUpdateAccessMode.mockReturnValue({
mockMutate.mockImplementation((_: unknown, options?: { onSuccess?: () => void }) => {
options?.onSuccess?.()
})
mockUseMutation.mockReturnValue({
isPending: false,
mutateAsync: mockMutateAsync,
mutate: mockMutate,
})
mockUseAppWhiteListSubjects.mockReturnValue({
isPending: false,
@ -117,33 +125,39 @@ beforeEach(() => {
// AccessControlItem handles selected vs. unselected styling and click state updates
describe('AccessControlItem', () => {
it('should update current menu when selecting a different access type', () => {
useAccessControlStore.setState({ currentMenu: AccessMode.PUBLIC })
render(
<AccessControlItem type={AccessMode.ORGANIZATION}>
<span>Organization Only</span>
</AccessControlItem>,
const harness = createAccessControlDraftHarness(
<AccessControlRadioGroupHarness>
<AccessControlItem type={AccessMode.ORGANIZATION}>
<span>Organization Only</span>
</AccessControlItem>
</AccessControlRadioGroupHarness>,
{ currentMenu: AccessMode.PUBLIC },
)
render(harness.element)
const option = screen.getByText('Organization Only').parentElement as HTMLElement
const option = screen.getByRole('radio', { name: 'Organization Only' })
expect(option).toHaveClass('cursor-pointer')
fireEvent.click(option)
expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.ORGANIZATION)
expect(harness.getSnapshot().currentMenu).toBe(AccessMode.ORGANIZATION)
})
it('should keep current menu when clicking the selected access type', () => {
useAccessControlStore.setState({ currentMenu: AccessMode.ORGANIZATION })
render(
<AccessControlItem type={AccessMode.ORGANIZATION}>
<span>Organization Only</span>
</AccessControlItem>,
const harness = createAccessControlDraftHarness(
<AccessControlRadioGroupHarness>
<AccessControlItem type={AccessMode.ORGANIZATION}>
<span>Organization Only</span>
</AccessControlItem>
</AccessControlRadioGroupHarness>,
{ currentMenu: AccessMode.ORGANIZATION },
)
render(harness.element)
const option = screen.getByText('Organization Only').parentElement as HTMLElement
const option = screen.getByRole('radio', { name: 'Organization Only' })
fireEvent.click(option)
expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.ORGANIZATION)
expect(harness.getSnapshot().currentMenu).toBe(AccessMode.ORGANIZATION)
})
})
@ -180,32 +194,40 @@ describe('AccessControlDialog', () => {
// SpecificGroupsOrMembers syncs store state with fetched data and supports removals
describe('SpecificGroupsOrMembers', () => {
it('should render collapsed view when not in specific selection mode', () => {
useAccessControlStore.setState({ currentMenu: AccessMode.ORGANIZATION })
const harness = createAccessControlDraftHarness(
<SpecificGroupsOrMembers />,
{ currentMenu: AccessMode.ORGANIZATION },
)
render(<SpecificGroupsOrMembers />)
render(harness.element)
expect(screen.getByText('app.accessControlDialog.accessItems.specific')).toBeInTheDocument()
expect(screen.queryByText(baseGroup.name)).not.toBeInTheDocument()
})
it('should show loading state while pending', async () => {
useAccessControlStore.setState({ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS })
mockUseAppWhiteListSubjects.mockReturnValue({
isPending: true,
data: undefined,
})
const harness = createAccessControlDraftHarness(
<SpecificGroupsOrMembers loading />,
{ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS },
)
const { container } = render(<SpecificGroupsOrMembers />)
render(harness.element)
await waitFor(() => {
expect(container.querySelector('.spin-animation')).toBeInTheDocument()
})
expect(screen.getByRole('status', { name: 'common.loading' })).toBeInTheDocument()
})
it('should render fetched groups and members and support removal', async () => {
useAccessControlStore.setState({ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS })
const harness = createAccessControlDraftHarness(
<SpecificGroupsOrMembers />,
{
appId: 'app-1',
currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS,
specificGroups: [baseGroup],
specificMembers: [baseMember],
},
)
render(<SpecificGroupsOrMembers />)
render(harness.element)
await waitFor(() => {
expect(screen.getByText(baseGroup.name)).toBeInTheDocument()
@ -234,8 +256,12 @@ describe('SpecificGroupsOrMembers', () => {
describe('AddMemberOrGroupDialog', () => {
it('should open search popover and display candidates', async () => {
const user = userEvent.setup()
const harness = createAccessControlDraftHarness(
<AddMemberOrGroupDialog />,
{ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS },
)
render(<AddMemberOrGroupDialog />)
render(harness.element)
await user.click(screen.getByText('common.operation.add'))
@ -246,17 +272,21 @@ describe('AddMemberOrGroupDialog', () => {
it('should allow selecting members and expanding groups', async () => {
const user = userEvent.setup()
render(<AddMemberOrGroupDialog />)
const harness = createAccessControlDraftHarness(
<AddMemberOrGroupDialog />,
{ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS },
)
render(harness.element)
await user.click(screen.getByText('common.operation.add'))
const expandButton = screen.getByText('app.accessControlDialog.operateGroupAndMember.expand')
await user.click(expandButton)
expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([baseGroup])
expect(harness.getSnapshot().selectedGroupsForBreadcrumb).toEqual([baseGroup])
await user.click(screen.getByRole('option', { name: /Member One/ }))
expect(useAccessControlStore.getState().specificMembers).toEqual([baseMember])
expect(harness.getSnapshot().specificMembers).toEqual([baseMember])
})
it('should update the keyword, fetch the next page, and support deselection and breadcrumb reset', async () => {
@ -269,7 +299,11 @@ describe('AddMemberOrGroupDialog', () => {
})
const user = userEvent.setup()
render(<AddMemberOrGroupDialog />)
const harness = createAccessControlDraftHarness(
<AddMemberOrGroupDialog />,
{ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS },
)
render(harness.element)
await user.click(screen.getByText('common.operation.add'))
await user.type(screen.getByPlaceholderText('app.accessControlDialog.operateGroupAndMember.searchPlaceholder'), 'Group')
@ -286,9 +320,9 @@ describe('AddMemberOrGroupDialog', () => {
fireEvent.click(screen.getByText('app.accessControlDialog.operateGroupAndMember.expand'))
fireEvent.click(screen.getByText('app.accessControlDialog.operateGroupAndMember.allMembers'))
expect(useAccessControlStore.getState().specificGroups).toEqual([])
expect(useAccessControlStore.getState().specificMembers).toEqual([])
expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([])
expect(harness.getSnapshot().specificGroups).toEqual([])
expect(harness.getSnapshot().specificMembers).toEqual([])
expect(harness.getSnapshot().selectedGroupsForBreadcrumb).toEqual([])
expect(fetchNextPage).not.toHaveBeenCalled()
})
@ -301,7 +335,11 @@ describe('AddMemberOrGroupDialog', () => {
})
const user = userEvent.setup()
render(<AddMemberOrGroupDialog />)
const harness = createAccessControlDraftHarness(
<AddMemberOrGroupDialog />,
{ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS },
)
render(harness.element)
await user.click(screen.getByText('common.operation.add'))
@ -315,10 +353,6 @@ describe('AccessControl', () => {
const onClose = vi.fn()
const onConfirm = vi.fn()
const toastSpy = vi.spyOn(toast, 'success').mockReturnValue('toast-success')
useAccessControlStore.setState({
specificGroups: [baseGroup],
specificMembers: [baseMember],
})
const app = {
id: 'app-id-1',
access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS,
@ -332,21 +366,22 @@ describe('AccessControl', () => {
/>,
)
await waitFor(() => {
expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.SPECIFIC_GROUPS_MEMBERS)
})
fireEvent.click(screen.getByText('common.operation.confirm'))
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith({
appId: app.id,
accessMode: AccessMode.SPECIFIC_GROUPS_MEMBERS,
subjects: [
{ subjectId: baseGroup.id, subjectType: SubjectType.GROUP },
{ subjectId: baseMember.id, subjectType: SubjectType.ACCOUNT },
],
})
expect(mockMutate).toHaveBeenCalledWith(
{
body: {
appId: app.id,
accessMode: AccessMode.SPECIFIC_GROUPS_MEMBERS,
subjects: [
{ subjectId: baseGroup.id, subjectType: EnterpriseSubjectType.SUBJECT_TYPE_GROUP },
{ subjectId: baseMember.id, subjectType: EnterpriseSubjectType.SUBJECT_TYPE_ACCOUNT },
],
},
},
expect.objectContaining({ onSuccess: expect.any(Function) }),
)
expect(toastSpy).toHaveBeenCalledWith('app.accessControlDialog.updateSuccess')
expect(onConfirm).toHaveBeenCalled()
})

View File

@ -1,9 +1,9 @@
import type { AccessControlAccount, AccessControlGroup, Subject } from '@/models/access-control'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import useAccessControlStore from '@/context/access-control-store'
import { SubjectType } from '@/models/access-control'
import AddMemberOrGroupDialog from '../add-member-or-group-pop'
import { AccessMode, SubjectType } from '@/models/access-control'
import { AddMemberOrGroupDialog } from '../add-member-or-group-pop'
import { createAccessControlDraftHarness } from './access-control-test-utils'
const mockUseSearchForWhiteListCandidates = vi.fn()
const intersectionObserverMocks = vi.hoisted(() => ({
@ -69,13 +69,6 @@ describe('AddMemberOrGroupDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
useAccessControlStore.setState({
appId: 'app-1',
specificGroups: [],
specificMembers: [],
currentMenu: SubjectType.GROUP as never,
selectedGroupsForBreadcrumb: [],
})
mockUseSearchForWhiteListCandidates.mockReturnValue({
isLoading: false,
isFetchingNextPage: false,
@ -88,7 +81,11 @@ describe('AddMemberOrGroupDialog', () => {
it('should open the search popover and display candidates', async () => {
const user = userEvent.setup()
render(<AddMemberOrGroupDialog />)
const harness = createAccessControlDraftHarness(
<AddMemberOrGroupDialog />,
{ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS },
)
render(harness.element)
await user.click(screen.getByText('common.operation.add'))
@ -99,16 +96,20 @@ describe('AddMemberOrGroupDialog', () => {
it('should allow expanding groups and selecting members', async () => {
const user = userEvent.setup()
render(<AddMemberOrGroupDialog />)
const harness = createAccessControlDraftHarness(
<AddMemberOrGroupDialog />,
{ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS },
)
render(harness.element)
await user.click(screen.getByText('common.operation.add'))
await user.click(screen.getByText('app.accessControlDialog.operateGroupAndMember.expand'))
expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([baseGroup])
expect(harness.getSnapshot().selectedGroupsForBreadcrumb).toEqual([baseGroup])
await user.click(screen.getByRole('option', { name: /Member One/ }))
expect(useAccessControlStore.getState().specificMembers).toEqual([baseMember])
expect(harness.getSnapshot().specificMembers).toEqual([baseMember])
})
it('should show the empty state when no candidates are returned', async () => {
@ -120,7 +121,11 @@ describe('AddMemberOrGroupDialog', () => {
})
const user = userEvent.setup()
render(<AddMemberOrGroupDialog />)
const harness = createAccessControlDraftHarness(
<AddMemberOrGroupDialog />,
{ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS },
)
render(harness.element)
await user.click(screen.getByText('common.operation.add'))
@ -128,9 +133,6 @@ describe('AddMemberOrGroupDialog', () => {
})
it('should keep breadcrumbs visible when the current group has no candidates', async () => {
useAccessControlStore.setState({
selectedGroupsForBreadcrumb: [baseGroup],
})
mockUseSearchForWhiteListCandidates.mockReturnValue({
isLoading: false,
isFetchingNextPage: false,
@ -139,7 +141,15 @@ describe('AddMemberOrGroupDialog', () => {
})
const user = userEvent.setup()
render(<AddMemberOrGroupDialog />)
const harness = createAccessControlDraftHarness(
<AddMemberOrGroupDialog />,
{
appId: 'app-1',
currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS,
selectedGroupsForBreadcrumb: [baseGroup],
},
)
render(harness.element)
await user.click(screen.getByText('common.operation.add'))
@ -149,6 +159,6 @@ describe('AddMemberOrGroupDialog', () => {
await user.click(screen.getByRole('button', { name: 'app.accessControlDialog.operateGroupAndMember.allMembers' }))
expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([])
expect(harness.getSnapshot().selectedGroupsForBreadcrumb).toEqual([])
})
})

View File

@ -3,9 +3,8 @@ import type { App } from '@/types/app'
import { toast } from '@langgenius/dify-ui/toast'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import useAccessControlStore from '@/context/access-control-store'
import { AccessMode } from '@/models/access-control'
import AccessControl from '../index'
import { AccessControl } from '../index'
let mockWebappAuth = {
enabled: true,
@ -18,20 +17,24 @@ const render = (ui: ReactElement) => renderWithSystemFeatures(ui, {
systemFeatures: { webapp_auth: mockWebappAuth },
})
const mockMutateAsync = vi.fn()
const mockUseUpdateAccessMode = vi.fn(() => ({
isPending: false,
mutateAsync: mockMutateAsync,
}))
const mockMutate = vi.fn()
const mockUseMutation = vi.hoisted(() => vi.fn())
const mockUseAppWhiteListSubjects = vi.fn()
const mockUseSearchForWhiteListCandidates = vi.fn()
vi.mock('@/service/access-control', () => ({
useAppWhiteListSubjects: (...args: unknown[]) => mockUseAppWhiteListSubjects(...args),
useSearchForWhiteListCandidates: (...args: unknown[]) => mockUseSearchForWhiteListCandidates(...args),
useUpdateAccessMode: () => mockUseUpdateAccessMode(),
}))
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useMutation: (...args: unknown[]) => mockUseMutation(...args),
}
})
describe('AccessControl', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -41,14 +44,13 @@ describe('AccessControl', () => {
allow_email_password_login: false,
allow_email_code_login: false,
}
useAccessControlStore.setState({
appId: '',
specificGroups: [],
specificMembers: [],
currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS,
selectedGroupsForBreadcrumb: [],
mockMutate.mockImplementation((_: unknown, options?: { onSuccess?: () => void }) => {
options?.onSuccess?.()
})
mockUseMutation.mockReturnValue({
isPending: false,
mutate: mockMutate,
})
mockMutateAsync.mockResolvedValue(undefined)
mockUseAppWhiteListSubjects.mockReturnValue({
isPending: false,
data: {
@ -81,18 +83,18 @@ describe('AccessControl', () => {
/>,
)
await waitFor(() => {
expect(useAccessControlStore.getState().appId).toBe(app.id)
expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.PUBLIC)
})
fireEvent.click(screen.getByText('common.operation.confirm'))
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith({
appId: app.id,
accessMode: AccessMode.PUBLIC,
})
expect(mockMutate).toHaveBeenCalledWith(
{
body: {
appId: app.id,
accessMode: AccessMode.PUBLIC,
},
},
expect.objectContaining({ onSuccess: expect.any(Function) }),
)
expect(toastSpy).toHaveBeenCalledWith('app.accessControlDialog.updateSuccess')
expect(onConfirm).toHaveBeenCalledTimes(1)
})
@ -116,4 +118,30 @@ describe('AccessControl', () => {
expect(screen.getByText('app.accessControlDialog.accessItems.external')).toBeInTheDocument()
expect(screen.getByText('app.accessControlDialog.accessItems.anyone')).toBeInTheDocument()
})
it('should prevent confirming specific access before subjects are loaded', () => {
mockUseAppWhiteListSubjects.mockReturnValue({
isPending: true,
data: undefined,
})
render(
<AccessControl
app={{ id: 'app-id-3', access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS } as App}
onClose={vi.fn()}
/>,
)
const confirmButton = screen.getByRole('button', { name: 'common.operation.confirm' })
const organizationOption = screen.getByRole('radio', {
name: 'app.accessControlDialog.accessItems.organization',
})
expect(confirmButton).toBeDisabled()
expect(organizationOption).toHaveAttribute('aria-disabled', 'true')
fireEvent.click(confirmButton)
expect(mockMutate).not.toHaveBeenCalled()
})
})

View File

@ -1,17 +1,13 @@
import type { AccessControlAccount, AccessControlGroup } from '@/models/access-control'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import useAccessControlStore from '@/context/access-control-store'
import { AccessMode } from '@/models/access-control'
import SpecificGroupsOrMembers from '../specific-groups-or-members'
import { SpecificGroupsOrMembers } from '../specific-groups-or-members'
import { createAccessControlDraftHarness } from './access-control-test-utils'
const mockUseAppWhiteListSubjects = vi.fn()
const mockUseSearchForWhiteListCandidates = vi.fn()
vi.mock('@/service/access-control', () => ({
useAppWhiteListSubjects: (...args: unknown[]) => mockUseAppWhiteListSubjects(...args),
}))
vi.mock('../add-member-or-group-pop', () => ({
default: () => <div data-testid="add-member-or-group-dialog" />,
useSearchForWhiteListCandidates: (...args: unknown[]) => mockUseSearchForWhiteListCandidates(...args),
}))
const createGroup = (overrides: Partial<AccessControlGroup> = {}): AccessControlGroup => ({
@ -36,50 +32,48 @@ describe('SpecificGroupsOrMembers', () => {
beforeEach(() => {
vi.clearAllMocks()
useAccessControlStore.setState({
appId: '',
specificGroups: [],
specificMembers: [],
currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS,
selectedGroupsForBreadcrumb: [],
})
mockUseAppWhiteListSubjects.mockReturnValue({
isPending: false,
data: {
groups: [baseGroup],
members: [baseMember],
},
mockUseSearchForWhiteListCandidates.mockReturnValue({
isLoading: false,
isFetchingNextPage: false,
fetchNextPage: vi.fn(),
data: { pages: [] },
})
})
it('should render the collapsed row when not in specific mode', () => {
useAccessControlStore.setState({
currentMenu: AccessMode.ORGANIZATION,
})
const harness = createAccessControlDraftHarness(
<SpecificGroupsOrMembers />,
{ currentMenu: AccessMode.ORGANIZATION },
)
render(<SpecificGroupsOrMembers />)
render(harness.element)
expect(screen.getByText('app.accessControlDialog.accessItems.specific')).toBeInTheDocument()
expect(screen.queryByTestId('add-member-or-group-dialog')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.operation.add' })).not.toBeInTheDocument()
})
it('should show loading while whitelist subjects are pending', async () => {
mockUseAppWhiteListSubjects.mockReturnValue({
isPending: true,
data: undefined,
})
it('should show loading when the selected subjects are pending', async () => {
const harness = createAccessControlDraftHarness(<SpecificGroupsOrMembers loading />)
render(harness.element)
const { container } = render(<SpecificGroupsOrMembers />)
expect(screen.getByRole('combobox', { name: 'common.operation.add' })).toBeDisabled()
await waitFor(() => {
expect(container.querySelector('.spin-animation')).toBeInTheDocument()
expect(screen.getByRole('status', { name: 'common.loading' })).toBeInTheDocument()
})
})
it('should render fetched groups and members and support removal', async () => {
useAccessControlStore.setState({ appId: 'app-1' })
const harness = createAccessControlDraftHarness(
<SpecificGroupsOrMembers />,
{
appId: 'app-1',
specificGroups: [baseGroup],
specificMembers: [baseMember],
},
)
render(<SpecificGroupsOrMembers />)
render(harness.element)
await waitFor(() => {
expect(screen.getByText(baseGroup.name)).toBeInTheDocument()
@ -91,9 +85,9 @@ describe('SpecificGroupsOrMembers', () => {
const memberRemove = removeButtons[1]!
fireEvent.click(groupRemove)
expect(useAccessControlStore.getState().specificGroups).toEqual([])
expect(harness.getSnapshot().specificGroups).toEqual([])
fireEvent.click(memberRemove)
expect(useAccessControlStore.getState().specificMembers).toEqual([])
expect(harness.getSnapshot().specificMembers).toEqual([])
})
})

View File

@ -0,0 +1,110 @@
'use client'
import type { ReactNode } from 'react'
import type { SpecificGroupsOrMembersProps } from './specific-groups-or-members'
import { Button } from '@langgenius/dify-ui/button'
import { DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
import { useTranslation } from 'react-i18next'
import { AccessMode } from '@/models/access-control'
import { AccessControlItem } from './access-control-item'
import { SpecificGroupsOrMembers, WebAppSSONotEnabledTip } from './specific-groups-or-members'
import { useAccessControlStore } from './store'
type AccessControlDialogContentProps = {
title?: ReactNode
description?: ReactNode
accessLabel?: ReactNode
hideExternal?: boolean
hideExternalTip?: boolean
saving?: boolean
controlsDisabled?: boolean
confirmDisabled?: boolean
specificGroupsOrMembersProps?: SpecificGroupsOrMembersProps
onClose: () => void
onConfirm: () => void
}
export function AccessControlDialogContent({
title,
description,
accessLabel,
hideExternal = false,
hideExternalTip = false,
saving = false,
controlsDisabled = false,
confirmDisabled = false,
specificGroupsOrMembersProps,
onClose,
onConfirm,
}: AccessControlDialogContentProps) {
const { t } = useTranslation()
const currentMenu = useAccessControlStore(s => s.currentMenu)
const setCurrentMenu = useAccessControlStore(s => s.setCurrentMenu)
return (
<div className="flex flex-col gap-y-3">
<div className="pt-6 pr-14 pb-3 pl-6">
<DialogTitle className="title-2xl-semi-bold text-text-primary">
{title ?? t('accessControlDialog.title', { ns: 'app' })}
</DialogTitle>
<DialogDescription className="mt-1 system-xs-regular text-text-tertiary">
{description ?? t('accessControlDialog.description', { ns: 'app' })}
</DialogDescription>
</div>
<RadioGroup<AccessMode>
value={currentMenu}
onValueChange={setCurrentMenu}
className="flex flex-col items-stretch gap-y-1 px-6 pb-3"
aria-labelledby="access-control-options-label"
disabled={controlsDisabled}
>
<div className="leading-6">
<p id="access-control-options-label" className="system-sm-medium text-text-tertiary">
{accessLabel ?? t('accessControlDialog.accessLabel', { ns: 'app' })}
</p>
</div>
<AccessControlItem type={AccessMode.ORGANIZATION}>
<div className="flex items-center p-3">
<div className="flex grow items-center gap-x-2">
<span className="i-ri-building-line size-4 text-text-primary" aria-hidden="true" />
<p className="system-sm-medium text-text-primary">
{t('accessControlDialog.accessItems.organization', { ns: 'app' })}
</p>
</div>
</div>
</AccessControlItem>
<AccessControlItem type={AccessMode.SPECIFIC_GROUPS_MEMBERS}>
<SpecificGroupsOrMembers {...specificGroupsOrMembersProps} />
</AccessControlItem>
{!hideExternal && (
<AccessControlItem type={AccessMode.EXTERNAL_MEMBERS}>
<div className="flex items-center p-3">
<div className="flex grow items-center gap-x-2">
<span className="i-ri-verified-badge-line size-4 text-text-primary" aria-hidden="true" />
<p className="system-sm-medium text-text-primary">
{t('accessControlDialog.accessItems.external', { ns: 'app' })}
</p>
</div>
{!hideExternalTip && <WebAppSSONotEnabledTip />}
</div>
</AccessControlItem>
)}
<AccessControlItem type={AccessMode.PUBLIC}>
<div className="flex items-center gap-x-2 p-3">
<span className="i-ri-global-line size-4 text-text-primary" aria-hidden="true" />
<p className="system-sm-medium text-text-primary">
{t('accessControlDialog.accessItems.anyone', { ns: 'app' })}
</p>
</div>
</AccessControlItem>
</RadioGroup>
<div className="flex items-center justify-end gap-x-2 p-6 pt-5">
<Button disabled={saving} onClick={onClose}>{t('operation.cancel', { ns: 'common' })}</Button>
<Button disabled={confirmDisabled || saving} loading={saving} variant="primary" onClick={onConfirm}>
{t('operation.confirm', { ns: 'common' })}
</Button>
</div>
</div>
)
}

View File

@ -5,7 +5,6 @@ import {
DialogCloseButton,
DialogContent,
} from '@langgenius/dify-ui/dialog'
import { useCallback } from 'react'
type DialogProps = {
className?: string
@ -14,21 +13,17 @@ type DialogProps = {
onClose?: () => void
}
const AccessControlDialog = ({
export function AccessControlDialog({
className,
children,
show,
onClose,
}: DialogProps) => {
const close = useCallback(() => {
onClose?.()
}, [onClose])
}: DialogProps) {
return (
<Dialog open={show} disablePointerDismissal onOpenChange={open => !open && close()}>
<Dialog open={show} disablePointerDismissal onOpenChange={open => !open && onClose?.()}>
<DialogContent
className={cn(
'h-auto max-h-[calc(100dvh-2rem)] min-h-[323px] w-[600px] max-w-none overflow-y-auto rounded-2xl border-none bg-components-panel-bg p-0 shadow-xl transition-all',
'h-auto max-h-[calc(100dvh-2rem)] min-h-[323px] w-[600px] max-w-none overflow-y-auto rounded-2xl border-none bg-components-panel-bg p-0 shadow-xl transition-shadow',
className,
)}
>
@ -38,5 +33,3 @@ const AccessControlDialog = ({
</Dialog>
)
}
export default AccessControlDialog

View File

@ -1,37 +1,26 @@
'use client'
import type { FC, PropsWithChildren } from 'react'
import type { PropsWithChildren } from 'react'
import type { AccessMode } from '@/models/access-control'
import useAccessControlStore from '@/context/access-control-store'
import { cn } from '@langgenius/dify-ui/cn'
import { RadioRoot } from '@langgenius/dify-ui/radio'
type AccessControlItemProps = PropsWithChildren<{
export function AccessControlItem({ type, children }: PropsWithChildren<{
type: AccessMode
}>
const AccessControlItem: FC<AccessControlItemProps> = ({ type, children }) => {
const currentMenu = useAccessControlStore(s => s.currentMenu)
const setCurrentMenu = useAccessControlStore(s => s.setCurrentMenu)
if (currentMenu !== type) {
return (
<div
className="cursor-pointer rounded-[10px] border
border-components-option-card-option-border bg-components-option-card-option-bg
hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover"
onClick={() => setCurrentMenu(type)}
>
{children}
</div>
)
}
}>) {
return (
<div className="rounded-[10px] border-[1.5px]
border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg shadow-sm"
<RadioRoot<AccessMode>
value={type}
variant="unstyled"
render={<div />}
className={cn(
'cursor-pointer rounded-[10px] border-[0.5px] border-components-option-card-option-border bg-components-option-card-option-bg shadow-xs transition-colors',
'hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover',
'focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
'data-checked:border-components-option-card-option-selected-border data-checked:bg-components-option-card-option-selected-bg data-checked:ring-[0.5px] data-checked:ring-components-option-card-option-selected-border data-checked:ring-inset',
'data-disabled:cursor-not-allowed data-disabled:opacity-60 data-disabled:hover:border-components-option-card-option-border data-disabled:hover:bg-components-option-card-option-bg',
)}
>
{children}
</div>
</RadioRoot>
)
}
AccessControlItem.displayName = 'AccessControlItem'
export default AccessControlItem

View File

@ -0,0 +1,208 @@
'use client'
import type { ComboboxRootChangeEventDetails } from '@langgenius/dify-ui/combobox'
import type { AccessSubjectSelectionProps } from './types'
import type {
AccessControlGroup,
Subject,
} from '@/models/access-control'
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxInputGroup,
ComboboxList,
ComboboxStatus,
ComboboxTrigger,
} from '@langgenius/dify-ui/combobox'
import { useDebounce } from 'ahooks'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import { SkeletonRectangle } from '@/app/components/base/skeleton'
import { useSearchForWhiteListCandidates } from '@/service/access-control'
import { SelectedGroupsBreadCrumb, SubjectItem } from './subject-options'
import {
getSubjectLabel,
getSubjectValue,
isSameSubject,
selectionValueToSubjects,
subjectsToSelectionValue,
} from './utils'
type AccessSubjectAddButtonProps = AccessSubjectSelectionProps & {
disabled?: boolean
breadcrumbGroups?: AccessControlGroup[]
onBreadcrumbGroupsChange?: (groups: AccessControlGroup[]) => void
}
export function AccessSubjectAddButton({
selectedGroups,
selectedMembers,
onChange,
disabled,
breadcrumbGroups,
onBreadcrumbGroupsChange,
}: AccessSubjectAddButtonProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [keyword, setKeyword] = useState('')
const [internalBreadcrumbGroups, setInternalBreadcrumbGroups] = useState<AccessControlGroup[]>([])
const scrollRootRef = useRef<HTMLDivElement>(null)
const anchorRef = useRef<HTMLDivElement>(null)
const selectedGroupsForBreadcrumb = breadcrumbGroups ?? internalBreadcrumbGroups
const setSelectedGroupsForBreadcrumb = onBreadcrumbGroupsChange ?? setInternalBreadcrumbGroups
const debouncedKeyword = useDebounce(keyword, { wait: 500 })
const lastAvailableGroup = selectedGroupsForBreadcrumb[selectedGroupsForBreadcrumb.length - 1]
const { isLoading, isFetchingNextPage, fetchNextPage, data } = useSearchForWhiteListCandidates({
keyword: debouncedKeyword,
groupId: lastAvailableGroup?.id,
resultsPerPage: 10,
}, open && !disabled)
const pages = data?.pages ?? []
const subjects = pages.flatMap(page => page.subjects ?? [])
const selectedSubjects = selectionValueToSubjects({
groups: selectedGroups,
members: selectedMembers,
})
const hasResults = pages.length > 0 && subjects.length > 0
const shouldShowBreadcrumb = hasResults || selectedGroupsForBreadcrumb.length > 0
const hasMore = pages[pages.length - 1]?.hasMore ?? false
useEffect(() => {
let observer: IntersectionObserver | undefined
if (anchorRef.current) {
observer = new IntersectionObserver((entries) => {
if (entries[0]!.isIntersecting && !isLoading && !isFetchingNextPage && hasMore)
fetchNextPage()
}, { root: scrollRootRef.current, rootMargin: '20px' })
observer.observe(anchorRef.current)
}
return () => observer?.disconnect()
}, [fetchNextPage, hasMore, isFetchingNextPage, isLoading])
const handleOpenChange = (nextOpen: boolean) => {
if (nextOpen && disabled)
return
if (!nextOpen)
setKeyword('')
setOpen(nextOpen)
}
const handleInputValueChange = (inputValue: string, details: ComboboxRootChangeEventDetails) => {
if (!disabled && details.reason !== 'item-press')
setKeyword(inputValue)
}
const handleValueChange = (nextSubjects: Subject[]) => {
onChange(subjectsToSelectionValue(nextSubjects))
}
return (
<Combobox<Subject, true>
multiple
open={open}
value={selectedSubjects}
inputValue={keyword}
items={subjects}
disabled={disabled}
itemToStringLabel={getSubjectLabel}
itemToStringValue={getSubjectValue}
isItemEqualToValue={isSameSubject}
filter={null}
onOpenChange={handleOpenChange}
onInputValueChange={handleInputValueChange}
onValueChange={handleValueChange}
>
<ComboboxTrigger
aria-label={t('operation.add', { ns: 'common' })}
icon={false}
size="small"
disabled={disabled}
className="h-6 w-auto min-w-[52px] shrink-0 rounded-md border-0 bg-transparent px-2 py-0 text-xs font-medium text-components-button-secondary-accent-text hover:bg-state-accent-hover focus-visible:bg-state-accent-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-state-accent-hover"
>
<span className="inline-flex min-w-0 items-center justify-center gap-x-0.5 whitespace-nowrap">
<span className="i-ri-add-circle-fill size-4 shrink-0" aria-hidden="true" />
<span className="shrink-0">{t('operation.add', { ns: 'common' })}</span>
</span>
</ComboboxTrigger>
<ComboboxContent
placement="bottom-end"
alignOffset={300}
popupClassName="relative flex max-h-[400px] w-[400px] flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-0 shadow-lg backdrop-blur-[5px]"
>
<div ref={scrollRootRef} className="min-h-0 overflow-y-auto">
<div className="sticky top-0 z-10 bg-components-panel-bg-blur p-2 pb-0.5 backdrop-blur-[5px]">
<ComboboxInputGroup className="h-8 min-h-8 px-2">
<span className="mr-0.5 i-ri-search-line size-4 shrink-0 text-text-tertiary" aria-hidden="true" />
<ComboboxInput
aria-label={t('accessControlDialog.operateGroupAndMember.searchPlaceholder', { ns: 'app' })}
placeholder={t('accessControlDialog.operateGroupAndMember.searchPlaceholder', { ns: 'app' })}
className="block h-4.5 grow px-1 py-0 text-[13px] text-text-primary"
/>
</ComboboxInputGroup>
</div>
{isLoading
? (
<ComboboxStatus className="p-1">
<SubjectOptionsSkeleton />
</ComboboxStatus>
)
: (
<>
{shouldShowBreadcrumb && (
<div className="flex h-7 items-center px-2 py-0.5">
<SelectedGroupsBreadCrumb
selectedGroupsForBreadcrumb={selectedGroupsForBreadcrumb}
onChange={setSelectedGroupsForBreadcrumb}
/>
</div>
)}
{hasResults
? (
<>
<ComboboxList className="max-h-none p-1">
{(subject: Subject) => (
<SubjectItem
key={getSubjectValue(subject)}
subject={subject}
selectedGroups={selectedGroups}
selectedMembers={selectedMembers}
onExpandGroup={group => setSelectedGroupsForBreadcrumb([...selectedGroupsForBreadcrumb, group])}
/>
)}
</ComboboxList>
{isFetchingNextPage && <Loading />}
<div ref={anchorRef} className="h-0" />
</>
)
: (
<ComboboxEmpty className="flex h-7 items-center justify-center px-2 py-0.5">
{t('accessControlDialog.operateGroupAndMember.noResult', { ns: 'app' })}
</ComboboxEmpty>
)}
</>
)}
</div>
</ComboboxContent>
</Combobox>
)
}
function SubjectOptionsSkeleton() {
return (
<div className="flex flex-col gap-1">
{[0, 1, 2, 3, 4].map(index => (
<div key={index} className="flex min-h-8 items-center gap-2 rounded-lg p-1 pl-2">
<SkeletonRectangle className="my-0 size-4 shrink-0 animate-pulse rounded-sm" />
<SkeletonRectangle className="my-0 size-5 shrink-0 animate-pulse rounded-full" />
<SkeletonRectangle className="my-0 h-3.5 w-32 animate-pulse" />
<SkeletonRectangle className="my-0 h-3 w-16 animate-pulse" />
</div>
))}
</div>
)
}

View File

@ -0,0 +1,207 @@
'use client'
import type { ReactNode } from 'react'
import type { AccessSubjectSelectionProps } from './types'
import type {
AccessControlAccount,
AccessControlGroup,
} from '@/models/access-control'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle } from '@/app/components/base/skeleton'
type AccessSubjectSelectionListProps = AccessSubjectSelectionProps & {
loading?: boolean
className?: string
}
export function AccessSubjectSelectionList({
selectedGroups,
selectedMembers,
onChange,
loading,
className,
}: AccessSubjectSelectionListProps) {
return (
<div className={cn('flex max-h-[400px] flex-col gap-y-2 overflow-y-auto rounded-lg bg-background-section p-2', className)}>
{loading
? <AccessSubjectSelectionListSkeleton />
: (
<RenderGroupsAndMembers
selectedGroups={selectedGroups}
selectedMembers={selectedMembers}
onChange={onChange}
/>
)}
</div>
)
}
function AccessSubjectSelectionListSkeleton() {
const { t } = useTranslation()
return (
<div role="status" aria-busy="true" aria-label={t('loading', { ns: 'common' })} className="flex flex-col gap-y-2">
<SkeletonRectangle className="my-0 h-3 w-14 animate-pulse" />
<div className="flex flex-row flex-wrap gap-1">
{[0, 1].map(index => (
<SelectedItemSkeleton key={index} withMeta />
))}
</div>
<SkeletonRectangle className="my-0 h-3 w-16 animate-pulse" />
<div className="flex flex-row flex-wrap gap-1">
{[0, 1, 2].map(index => (
<SelectedItemSkeleton key={index} />
))}
</div>
</div>
)
}
function SelectedItemSkeleton({ withMeta = false }: {
withMeta?: boolean
}) {
return (
<div className="flex items-center gap-x-1 rounded-full border-[0.5px] border-components-panel-border-subtle bg-components-badge-white-to-dark p-1 pr-1.5 shadow-xs">
<SkeletonRectangle className="my-0 size-5 animate-pulse rounded-full" />
<SkeletonRectangle className="my-0 h-3 w-20 animate-pulse" />
{withMeta && <SkeletonRectangle className="my-0 h-3 w-5 animate-pulse" />}
<SkeletonRectangle className="my-0 size-4 animate-pulse rounded-full" />
</div>
)
}
function RenderGroupsAndMembers({
selectedGroups,
selectedMembers,
onChange,
}: AccessSubjectSelectionProps) {
const { t } = useTranslation()
if (selectedGroups.length <= 0 && selectedMembers.length <= 0) {
return (
<div className="px-2 pt-5 pb-1.5">
<p className="text-center system-xs-regular text-text-tertiary">
{t('accessControlDialog.noGroupsOrMembers', { ns: 'app' })}
</p>
</div>
)
}
return (
<>
<p className="sticky top-0 system-2xs-medium-uppercase text-text-tertiary">
{t('accessControlDialog.groups', { ns: 'app', count: selectedGroups.length ?? 0 })}
</p>
<div className="flex flex-row flex-wrap gap-1">
{selectedGroups.map(group => (
<SelectedGroupItem
key={group.id}
group={group}
selectedGroups={selectedGroups}
selectedMembers={selectedMembers}
onChange={onChange}
/>
))}
</div>
<p className="sticky top-0 system-2xs-medium-uppercase text-text-tertiary">
{t('accessControlDialog.members', { ns: 'app', count: selectedMembers.length ?? 0 })}
</p>
<div className="flex flex-row flex-wrap gap-1">
{selectedMembers.map(member => (
<SelectedMemberItem
key={member.id}
member={member}
selectedGroups={selectedGroups}
selectedMembers={selectedMembers}
onChange={onChange}
/>
))}
</div>
</>
)
}
type SelectedGroupItemProps = AccessSubjectSelectionProps & {
group: AccessControlGroup
}
function SelectedGroupItem({
group,
selectedGroups,
selectedMembers,
onChange,
}: SelectedGroupItemProps) {
const handleRemoveGroup = () => {
onChange({
groups: selectedGroups.filter(selectedGroup => selectedGroup.id !== group.id),
members: selectedMembers,
})
}
return (
<SelectedBaseItem
icon={<span className="i-ri-organization-chart h-[14px] w-[14px] text-components-avatar-shape-fill-stop-0" aria-hidden="true" />}
onRemove={handleRemoveGroup}
>
<p className="system-xs-regular text-text-primary">{group.name}</p>
<p className="system-xs-regular text-text-tertiary">{group.groupSize}</p>
</SelectedBaseItem>
)
}
type SelectedMemberItemProps = AccessSubjectSelectionProps & {
member: AccessControlAccount
}
function SelectedMemberItem({
member,
selectedGroups,
selectedMembers,
onChange,
}: SelectedMemberItemProps) {
const handleRemoveMember = () => {
onChange({
groups: selectedGroups,
members: selectedMembers.filter(selectedMember => selectedMember.id !== member.id),
})
}
return (
<SelectedBaseItem
icon={<Avatar size="xxs" avatar={null} name={member.name} />}
onRemove={handleRemoveMember}
>
<p className="system-xs-regular text-text-primary">{member.name}</p>
</SelectedBaseItem>
)
}
type SelectedBaseItemProps = {
icon: ReactNode
children: ReactNode
onRemove?: () => void
}
function SelectedBaseItem({ icon, onRemove, children }: SelectedBaseItemProps) {
const { t } = useTranslation()
return (
<div className="group flex flex-row items-center gap-x-1 rounded-full border-[0.5px] border-components-panel-border-subtle bg-components-badge-white-to-dark p-1 pr-1.5 shadow-xs">
<div className="size-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid">
<div className="flex size-full items-center justify-center bg-[image:var(--color-access-app-icon-mask-bg)]">
{icon}
</div>
</div>
{children}
<button
type="button"
className="flex size-4 cursor-pointer items-center justify-center border-none bg-transparent p-0 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
aria-label={t('operation.remove', { ns: 'common' })}
onClick={onRemove}
>
<span className="i-ri-close-circle-fill h-[14px] w-[14px] text-text-quaternary" aria-hidden="true" />
</button>
</div>
)
}

View File

@ -0,0 +1,217 @@
'use client'
import type { ReactNode } from 'react'
import type {
AccessControlAccount,
AccessControlGroup,
Subject,
SubjectAccount,
SubjectGroup,
} from '@/models/access-control'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
ComboboxItem,
ComboboxItemText,
} from '@langgenius/dify-ui/combobox'
import { useTranslation } from 'react-i18next'
import { useSelector } from '@/context/app-context'
import { SubjectType } from '@/models/access-control'
export function SubjectItem({
subject,
selectedGroups,
selectedMembers,
onExpandGroup,
}: {
subject: Subject
selectedGroups: AccessControlGroup[]
selectedMembers: AccessControlAccount[]
onExpandGroup: (group: AccessControlGroup) => void
}) {
if (subject.subjectType === SubjectType.GROUP) {
return (
<GroupItem
group={(subject as SubjectGroup).groupData}
subject={subject}
selectedGroups={selectedGroups}
onExpandGroup={onExpandGroup}
/>
)
}
return (
<MemberItem
member={(subject as SubjectAccount).accountData}
subject={subject}
selectedMembers={selectedMembers}
/>
)
}
export function SelectedGroupsBreadCrumb({
selectedGroupsForBreadcrumb,
onChange,
}: {
selectedGroupsForBreadcrumb: AccessControlGroup[]
onChange: (groups: AccessControlGroup[]) => void
}) {
const { t } = useTranslation()
const handleBreadCrumbClick = (index: number) => {
const newGroups = selectedGroupsForBreadcrumb.slice(0, index + 1)
onChange(newGroups)
}
const handleReset = () => {
onChange([])
}
const hasBreadcrumb = selectedGroupsForBreadcrumb.length > 0
return (
<div className="flex h-7 items-center gap-x-0.5 px-2 py-0.5">
{hasBreadcrumb
? (
<button
type="button"
className="cursor-pointer border-none bg-transparent p-0 text-left system-xs-regular text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
onClick={handleReset}
>
{t('accessControlDialog.operateGroupAndMember.allMembers', { ns: 'app' })}
</button>
)
: (
<span className="system-xs-regular text-text-tertiary">{t('accessControlDialog.operateGroupAndMember.allMembers', { ns: 'app' })}</span>
)}
{selectedGroupsForBreadcrumb.map((group, index) => {
const isLastGroup = index === selectedGroupsForBreadcrumb.length - 1
return (
<div key={group.id} className="flex items-center gap-x-0.5 system-xs-regular text-text-tertiary">
<span>/</span>
{isLastGroup
? <span>{group.name}</span>
: (
<button
type="button"
className="cursor-pointer border-none bg-transparent p-0 text-left system-xs-regular text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
onClick={() => handleBreadCrumbClick(index)}
>
{group.name}
</button>
)}
</div>
)
})}
</div>
)
}
type GroupItemProps = {
group: AccessControlGroup
subject: Subject
selectedGroups: AccessControlGroup[]
onExpandGroup: (group: AccessControlGroup) => void
}
function GroupItem({ group, subject, selectedGroups, onExpandGroup }: GroupItemProps) {
const { t } = useTranslation()
const isChecked = selectedGroups.some(selectedGroup => selectedGroup.id === group.id)
return (
<div className="flex items-center gap-2 rounded-lg hover:bg-state-base-hover">
<ComboboxBaseItem subject={subject}>
<SelectionBox checked={isChecked} />
<ComboboxItemText className="flex grow items-center px-0">
<div className="mr-2 size-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid">
<div className="flex size-full items-center justify-center bg-[image:var(--color-access-app-icon-mask-bg)]">
<span className="i-ri-organization-chart h-[14px] w-[14px] text-components-avatar-shape-fill-stop-0" aria-hidden="true" />
</div>
</div>
<span className="mr-1 system-sm-medium text-text-secondary">{group.name}</span>
<span className="system-xs-regular text-text-tertiary">{group.groupSize}</span>
</ComboboxItemText>
</ComboboxBaseItem>
<Button
size="small"
disabled={isChecked}
variant="ghost-accent"
className="mr-1 flex shrink-0 items-center justify-between px-1.5 py-1"
onPointerDown={event => event.preventDefault()}
onClick={() => onExpandGroup(group)}
>
<span className="px-[3px]">{t('accessControlDialog.operateGroupAndMember.expand', { ns: 'app' })}</span>
<span className="i-ri-arrow-right-s-line size-4" aria-hidden="true" />
</Button>
</div>
)
}
type MemberItemProps = {
member: AccessControlAccount
subject: Subject
selectedMembers: AccessControlAccount[]
}
function MemberItem({ member, subject, selectedMembers }: MemberItemProps) {
const currentUser = useSelector(s => s.userProfile)
const { t } = useTranslation()
const isChecked = selectedMembers.some(selectedMember => selectedMember.id === member.id)
return (
<ComboboxBaseItem subject={subject} className="pr-3">
<SelectionBox checked={isChecked} />
<ComboboxItemText className="flex grow items-center px-0">
<div className="mr-2 size-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid">
<div className="flex size-full items-center justify-center bg-[image:var(--color-access-app-icon-mask-bg)]">
<Avatar size="xxs" avatar={null} name={member.name} />
</div>
</div>
<span className="mr-1 system-sm-medium text-text-secondary">{member.name}</span>
{currentUser.email === member.email && (
<span className="system-xs-regular text-text-tertiary">
(
{t('you', { ns: 'common' })}
)
</span>
)}
</ComboboxItemText>
<span className="system-xs-regular text-text-quaternary">{member.email}</span>
</ComboboxBaseItem>
)
}
type ComboboxBaseItemProps = {
className?: string
subject: Subject
children: ReactNode
}
function ComboboxBaseItem({ children, className, subject }: ComboboxBaseItemProps) {
return (
<ComboboxItem
value={subject}
className={cn(
'mx-0 flex min-h-8 grow grid-cols-none items-center gap-2 rounded-lg p-1 pl-2',
className,
)}
>
{children}
</ComboboxItem>
)
}
function SelectionBox({ checked }: { checked: boolean }) {
return (
<span
aria-hidden="true"
className={cn(
'flex size-4 shrink-0 items-center justify-center rounded-sm shadow-xs shadow-shadow-shadow-3',
checked
? 'bg-components-checkbox-bg text-components-checkbox-icon'
: 'border border-components-checkbox-border bg-components-checkbox-bg-unchecked',
)}
>
{checked && <span className="i-ri-check-line size-3" />}
</span>
)
}

View File

@ -0,0 +1,15 @@
import type {
AccessControlAccount,
AccessControlGroup,
} from '@/models/access-control'
export type AccessSubjectSelectionValue = {
groups: AccessControlGroup[]
members: AccessControlAccount[]
}
export type AccessSubjectSelectionProps = {
selectedGroups: AccessControlGroup[]
selectedMembers: AccessControlAccount[]
onChange: (value: AccessSubjectSelectionValue) => void
}

View File

@ -0,0 +1,64 @@
import type { AccessSubjectSelectionValue } from './types'
import type {
AccessControlAccount,
AccessControlGroup,
Subject,
SubjectAccount,
SubjectGroup,
} from '@/models/access-control'
import { SubjectType } from '@/models/access-control'
function groupToSubject(group: AccessControlGroup): SubjectGroup {
return {
subjectId: group.id,
subjectType: SubjectType.GROUP,
groupData: group,
}
}
function memberToSubject(member: AccessControlAccount): SubjectAccount {
return {
subjectId: member.id,
subjectType: SubjectType.ACCOUNT,
accountData: member,
}
}
export function getSubjectLabel(subject: Subject) {
if (subject.subjectType === SubjectType.GROUP)
return (subject as SubjectGroup).groupData.name
return (subject as SubjectAccount).accountData.name
}
export function getSubjectValue(subject: Subject) {
return `${subject.subjectType}:${subject.subjectId}`
}
export function isSameSubject(item: Subject, value: Subject) {
return item.subjectId === value.subjectId && item.subjectType === value.subjectType
}
export function selectionValueToSubjects({
groups,
members,
}: AccessSubjectSelectionValue) {
return [
...groups.map(groupToSubject),
...members.map(memberToSubject),
]
}
export function subjectsToSelectionValue(subjects: Subject[]): AccessSubjectSelectionValue {
const groups: AccessControlGroup[] = []
const members: AccessControlAccount[] = []
subjects.forEach((subject) => {
if (subject.subjectType === SubjectType.GROUP)
groups.push((subject as SubjectGroup).groupData)
else
members.push((subject as SubjectAccount).accountData)
})
return { groups, members }
}

View File

@ -1,369 +1,31 @@
'use client'
import type { ComboboxRootChangeEventDetails } from '@langgenius/dify-ui/combobox'
import type { AccessControlAccount, AccessControlGroup, Subject, SubjectAccount, SubjectGroup } from '@/models/access-control'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxInputGroup,
ComboboxItem,
ComboboxItemText,
ComboboxList,
ComboboxStatus,
ComboboxTrigger,
} from '@langgenius/dify-ui/combobox'
import { RiAddCircleFill, RiArrowRightSLine, RiOrganizationChart } from '@remixicon/react'
import { useDebounce } from 'ahooks'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useSelector } from '@/context/app-context'
import { SubjectType } from '@/models/access-control'
import { useSearchForWhiteListCandidates } from '@/service/access-control'
import useAccessControlStore from '../../../../context/access-control-store'
import Loading from '../../base/loading'
export default function AddMemberOrGroupDialog() {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [keyword, setKeyword] = useState('')
const scrollRootRef = useRef<HTMLDivElement>(null)
const anchorRef = useRef<HTMLDivElement>(null)
import {
AccessSubjectAddButton,
} from './access-subject-selector/add-button'
import { useAccessControlStore } from './store'
export function AddMemberOrGroupDialog({ disabled = false }: {
disabled?: boolean
}) {
const specificGroups = useAccessControlStore(s => s.specificGroups)
const setSpecificGroups = useAccessControlStore(s => s.setSpecificGroups)
const specificMembers = useAccessControlStore(s => s.specificMembers)
const setSpecificMembers = useAccessControlStore(s => s.setSpecificMembers)
const selectedGroupsForBreadcrumb = useAccessControlStore(s => s.selectedGroupsForBreadcrumb)
const debouncedKeyword = useDebounce(keyword, { wait: 500 })
const lastAvailableGroup = selectedGroupsForBreadcrumb[selectedGroupsForBreadcrumb.length - 1]
const { isLoading, isFetchingNextPage, fetchNextPage, data } = useSearchForWhiteListCandidates({ keyword: debouncedKeyword, groupId: lastAvailableGroup?.id, resultsPerPage: 10 }, open)
const pages = data?.pages ?? []
const subjects = pages.flatMap(page => page.subjects ?? [])
const selectedSubjects = [
...specificGroups.map(groupToSubject),
...specificMembers.map(memberToSubject),
]
const hasResults = pages.length > 0 && subjects.length > 0
const shouldShowBreadcrumb = hasResults || selectedGroupsForBreadcrumb.length > 0
const hasMore = pages[pages.length - 1]?.hasMore ?? false
useEffect(() => {
let observer: IntersectionObserver | undefined
if (anchorRef.current) {
observer = new IntersectionObserver((entries) => {
if (entries[0]!.isIntersecting && !isLoading && hasMore)
fetchNextPage()
}, { root: scrollRootRef.current, rootMargin: '20px' })
observer.observe(anchorRef.current)
}
return () => observer?.disconnect()
}, [isLoading, fetchNextPage, hasMore])
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen)
setKeyword('')
setOpen(nextOpen)
}
const handleInputValueChange = (inputValue: string, details: ComboboxRootChangeEventDetails) => {
if (details.reason !== 'item-press')
setKeyword(inputValue)
}
const handleValueChange = (nextSubjects: Subject[]) => {
const nextGroups: AccessControlGroup[] = []
const nextMembers: AccessControlAccount[] = []
for (const subject of nextSubjects) {
if (subject.subjectType === SubjectType.GROUP)
nextGroups.push((subject as SubjectGroup).groupData)
else
nextMembers.push((subject as SubjectAccount).accountData)
}
setSpecificGroups(nextGroups)
setSpecificMembers(nextMembers)
}
return (
<Combobox<Subject, true>
multiple
open={open}
value={selectedSubjects}
inputValue={keyword}
items={subjects}
itemToStringLabel={getSubjectLabel}
itemToStringValue={getSubjectValue}
isItemEqualToValue={isSameSubject}
filter={null}
onOpenChange={handleOpenChange}
onInputValueChange={handleInputValueChange}
onValueChange={handleValueChange}
>
<ComboboxTrigger
aria-label={t('operation.add', { ns: 'common' })}
icon={false}
size="small"
className="flex h-6 w-auto shrink-0 items-center gap-x-0.5 rounded-md border-0 bg-transparent px-2 py-0 text-xs font-medium text-components-button-secondary-accent-text hover:bg-state-accent-hover focus-visible:bg-state-accent-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-state-accent-hover"
>
<RiAddCircleFill className="size-4" aria-hidden="true" />
<span>{t('operation.add', { ns: 'common' })}</span>
</ComboboxTrigger>
<ComboboxContent
placement="bottom-end"
alignOffset={300}
popupClassName="relative flex max-h-[400px] w-[400px] flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-0 shadow-lg backdrop-blur-[5px]"
>
<div ref={scrollRootRef} className="min-h-0 overflow-y-auto">
<div className="sticky top-0 z-10 bg-components-panel-bg-blur p-2 pb-0.5 backdrop-blur-[5px]">
<ComboboxInputGroup className="h-8 min-h-8 px-2">
<span className="mr-0.5 i-ri-search-line size-4 shrink-0 text-text-tertiary" aria-hidden="true" />
<ComboboxInput
aria-label={t('accessControlDialog.operateGroupAndMember.searchPlaceholder', { ns: 'app' })}
placeholder={t('accessControlDialog.operateGroupAndMember.searchPlaceholder', { ns: 'app' })}
className="block h-4.5 grow px-1 py-0 text-[13px] text-text-primary"
/>
</ComboboxInputGroup>
</div>
{isLoading
? (
<ComboboxStatus className="p-1">
<Loading />
</ComboboxStatus>
)
: (
<>
{shouldShowBreadcrumb && (
<div className="flex h-7 items-center px-2 py-0.5">
<SelectedGroupsBreadCrumb />
</div>
)}
{hasResults
? (
<>
<ComboboxList className="max-h-none p-1">
{(subject: Subject) => <SubjectItem key={getSubjectValue(subject)} subject={subject} />}
</ComboboxList>
{isFetchingNextPage && <Loading />}
<div ref={anchorRef} className="h-0" />
</>
)
: (
<ComboboxEmpty className="flex h-7 items-center justify-center px-2 py-0.5">
{t('accessControlDialog.operateGroupAndMember.noResult', { ns: 'app' })}
</ComboboxEmpty>
)}
</>
)}
</div>
</ComboboxContent>
</Combobox>
)
}
function groupToSubject(group: AccessControlGroup): SubjectGroup {
return {
subjectId: group.id,
subjectType: SubjectType.GROUP,
groupData: group,
}
}
function memberToSubject(member: AccessControlAccount): SubjectAccount {
return {
subjectId: member.id,
subjectType: SubjectType.ACCOUNT,
accountData: member,
}
}
function getSubjectLabel(subject: Subject) {
if (subject.subjectType === SubjectType.GROUP)
return (subject as SubjectGroup).groupData.name
return (subject as SubjectAccount).accountData.name
}
function getSubjectValue(subject: Subject) {
return `${subject.subjectType}:${subject.subjectId}`
}
function isSameSubject(item: Subject, value: Subject) {
return item.subjectId === value.subjectId && item.subjectType === value.subjectType
}
function SubjectItem({ subject }: { subject: Subject }) {
if (subject.subjectType === SubjectType.GROUP)
return <GroupItem group={(subject as SubjectGroup).groupData} subject={subject} />
return <MemberItem member={(subject as SubjectAccount).accountData} subject={subject} />
}
function SelectedGroupsBreadCrumb() {
const selectedGroupsForBreadcrumb = useAccessControlStore(s => s.selectedGroupsForBreadcrumb)
const setSelectedGroupsForBreadcrumb = useAccessControlStore(s => s.setSelectedGroupsForBreadcrumb)
const { t } = useTranslation()
const handleBreadCrumbClick = (index: number) => {
const newGroups = selectedGroupsForBreadcrumb.slice(0, index + 1)
setSelectedGroupsForBreadcrumb(newGroups)
}
const handleReset = () => {
setSelectedGroupsForBreadcrumb([])
}
const hasBreadcrumb = selectedGroupsForBreadcrumb.length > 0
return (
<div className="flex h-7 items-center gap-x-0.5 px-2 py-0.5">
{hasBreadcrumb
? (
<button
type="button"
className="cursor-pointer border-none bg-transparent p-0 text-left system-xs-regular text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
onClick={handleReset}
>
{t('accessControlDialog.operateGroupAndMember.allMembers', { ns: 'app' })}
</button>
)
: (
<span className="system-xs-regular text-text-tertiary">{t('accessControlDialog.operateGroupAndMember.allMembers', { ns: 'app' })}</span>
)}
{selectedGroupsForBreadcrumb.map((group, index) => {
const isLastGroup = index === selectedGroupsForBreadcrumb.length - 1
return (
<div key={index} className="flex items-center gap-x-0.5 system-xs-regular text-text-tertiary">
<span>/</span>
{isLastGroup
? <span>{group.name}</span>
: (
<button
type="button"
className="cursor-pointer border-none bg-transparent p-0 text-left system-xs-regular text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
onClick={() => handleBreadCrumbClick(index)}
>
{group.name}
</button>
)}
</div>
)
})}
</div>
)
}
type GroupItemProps = {
group: AccessControlGroup
subject: Subject
}
function GroupItem({ group, subject }: GroupItemProps) {
const { t } = useTranslation()
const specificGroups = useAccessControlStore(s => s.specificGroups)
const selectedGroupsForBreadcrumb = useAccessControlStore(s => s.selectedGroupsForBreadcrumb)
const setSelectedGroupsForBreadcrumb = useAccessControlStore(s => s.setSelectedGroupsForBreadcrumb)
const isChecked = specificGroups.some(g => g.id === group.id)
const handleExpandClick = () => {
setSelectedGroupsForBreadcrumb([...selectedGroupsForBreadcrumb, group])
}
return (
<div className="flex items-center gap-2 rounded-lg hover:bg-state-base-hover">
<BaseItem subject={subject}>
<SelectionBox checked={isChecked} />
<ComboboxItemText className="flex grow items-center px-0">
<div className="mr-2 size-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid">
<div className="bg-access-app-icon-mask-bg flex size-full items-center justify-center">
<RiOrganizationChart className="h-[14px] w-[14px] text-components-avatar-shape-fill-stop-0" aria-hidden="true" />
</div>
</div>
<span className="mr-1 system-sm-medium text-text-secondary">{group.name}</span>
<span className="system-xs-regular text-text-tertiary">{group.groupSize}</span>
</ComboboxItemText>
</BaseItem>
<Button
size="small"
disabled={isChecked}
variant="ghost-accent"
className="mr-1 flex shrink-0 items-center justify-between px-1.5 py-1"
onPointerDown={event => event.preventDefault()}
onClick={handleExpandClick}
>
<span className="px-[3px]">{t('accessControlDialog.operateGroupAndMember.expand', { ns: 'app' })}</span>
<RiArrowRightSLine className="size-4" aria-hidden="true" />
</Button>
</div>
)
}
type MemberItemProps = {
member: AccessControlAccount
subject: Subject
}
function MemberItem({ member, subject }: MemberItemProps) {
const currentUser = useSelector(s => s.userProfile)
const { t } = useTranslation()
const specificMembers = useAccessControlStore(s => s.specificMembers)
const isChecked = specificMembers.some(m => m.id === member.id)
return (
<BaseItem subject={subject} className="pr-3">
<SelectionBox checked={isChecked} />
<ComboboxItemText className="flex grow items-center px-0">
<div className="mr-2 size-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid">
<div className="bg-access-app-icon-mask-bg flex size-full items-center justify-center">
<Avatar size="xxs" avatar={null} name={member.name} />
</div>
</div>
<span className="mr-1 system-sm-medium text-text-secondary">{member.name}</span>
{currentUser.email === member.email && (
<span className="system-xs-regular text-text-tertiary">
(
{t('you', { ns: 'common' })}
)
</span>
)}
</ComboboxItemText>
<span className="system-xs-regular text-text-quaternary">{member.email}</span>
</BaseItem>
)
}
type BaseItemProps = {
className?: string
subject: Subject
children: React.ReactNode
}
function BaseItem({ children, className, subject }: BaseItemProps) {
return (
<ComboboxItem
value={subject}
className={cn(
'mx-0 flex min-h-8 grow grid-cols-none items-center gap-2 rounded-lg p-1 pl-2',
className,
)}
>
{children}
</ComboboxItem>
)
}
function SelectionBox({ checked }: { checked: boolean }) {
return (
<span
aria-hidden="true"
className={cn(
'flex size-4 shrink-0 items-center justify-center rounded-sm shadow-xs shadow-shadow-shadow-3',
checked
? 'bg-components-checkbox-bg text-components-checkbox-icon'
: 'border border-components-checkbox-border bg-components-checkbox-bg-unchecked',
)}
>
{checked && <span className="i-ri-check-line size-3" />}
</span>
<AccessSubjectAddButton
selectedGroups={specificGroups}
selectedMembers={specificMembers}
disabled={disabled}
breadcrumbGroups={selectedGroupsForBreadcrumb}
onBreadcrumbGroupsChange={setSelectedGroupsForBreadcrumb}
onChange={({ groups, members }) => {
setSpecificGroups(groups)
setSpecificMembers(members)
}}
/>
)
}

View File

@ -1,20 +1,18 @@
'use client'
import type { Subject } from '@/models/access-control'
import type { Subject as EnterpriseSubject } from '@dify/contracts/enterprise/types.gen'
import type { App } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button'
import { DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
import { SubjectType as EnterpriseSubjectType } from '@dify/contracts/enterprise/types.gen'
import { toast } from '@langgenius/dify-ui/toast'
import { RiBuildingLine, RiGlobalLine, RiVerifiedBadgeLine } from '@remixicon/react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useCallback, useEffect } from 'react'
import { useMutation, useSuspenseQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { AccessMode, SubjectType } from '@/models/access-control'
import { useUpdateAccessMode } from '@/service/access-control'
import useAccessControlStore from '../../../../context/access-control-store'
import AccessControlDialog from './access-control-dialog'
import AccessControlItem from './access-control-item'
import SpecificGroupsOrMembers, { WebAppSSONotEnabledTip } from './specific-groups-or-members'
import { AccessMode } from '@/models/access-control'
import { useAppWhiteListSubjects } from '@/service/access-control'
import { consoleQuery } from '@/service/client'
import { AccessControlDialog } from './access-control-dialog'
import { AccessControlDialogContent } from './access-control-dialog-content'
import { useAccessControlStore } from './store'
import { AccessControlDraftProvider } from './store-provider'
type AccessControlProps = {
app: App
@ -22,92 +20,113 @@ type AccessControlProps = {
onConfirm?: () => void
}
export default function AccessControl(props: AccessControlProps) {
export function AccessControl(props: AccessControlProps) {
const { app, onClose, onConfirm } = props
const { t } = useTranslation()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const setAppId = useAccessControlStore(s => s.setAppId)
const specificGroups = useAccessControlStore(s => s.specificGroups)
const specificMembers = useAccessControlStore(s => s.specificMembers)
const currentMenu = useAccessControlStore(s => s.currentMenu)
const setCurrentMenu = useAccessControlStore(s => s.setCurrentMenu)
const hideTip = systemFeatures.webapp_auth.enabled
const hideExternalTip = systemFeatures.webapp_auth.enabled
&& (systemFeatures.webapp_auth.allow_sso
|| systemFeatures.webapp_auth.allow_email_password_login
|| systemFeatures.webapp_auth.allow_email_code_login)
const initialAccessMode = app.access_mode ?? AccessMode.SPECIFIC_GROUPS_MEMBERS
const whiteListSubjectsQuery = useAppWhiteListSubjects(
app.id,
initialAccessMode === AccessMode.SPECIFIC_GROUPS_MEMBERS,
)
const initialSpecificGroups = whiteListSubjectsQuery.data?.groups ?? []
const initialSpecificMembers = whiteListSubjectsQuery.data?.members ?? []
const draftKey = [
app.id,
initialAccessMode,
initialSpecificGroups.map(group => group.id).join(','),
initialSpecificMembers.map(member => member.id).join(','),
].join(':')
useEffect(() => {
setAppId(app.id)
setCurrentMenu(app.access_mode ?? AccessMode.SPECIFIC_GROUPS_MEMBERS)
}, [app, setAppId, setCurrentMenu])
return (
<AccessControlDraftProvider
draftKey={draftKey}
initialDraft={{
appId: app.id,
currentMenu: initialAccessMode,
specificGroups: initialSpecificGroups,
specificMembers: initialSpecificMembers,
selectedGroupsForBreadcrumb: [],
}}
>
<AccessControlForm
app={app}
hideExternalTip={hideExternalTip}
subjectsLoading={initialAccessMode === AccessMode.SPECIFIC_GROUPS_MEMBERS && whiteListSubjectsQuery.isPending}
onClose={onClose}
onConfirm={onConfirm}
successMessage={t('accessControlDialog.updateSuccess', { ns: 'app' })}
/>
</AccessControlDraftProvider>
)
}
const { isPending, mutateAsync: updateAccessMode } = useUpdateAccessMode()
const handleConfirm = useCallback(async () => {
function AccessControlForm({
app,
hideExternalTip,
subjectsLoading,
successMessage,
onClose,
onConfirm,
}: {
app: App
hideExternalTip: boolean
subjectsLoading: boolean
successMessage: string
onClose: () => void
onConfirm?: () => void
}) {
const specificGroups = useAccessControlStore(s => s.specificGroups)
const specificMembers = useAccessControlStore(s => s.specificMembers)
const currentMenu = useAccessControlStore(s => s.currentMenu)
const { isPending, mutate: updateAccessMode } = useMutation(consoleQuery.explore.updateAppAccessMode.mutationOptions())
function handleConfirm() {
const submitData: {
appId: string
accessMode: AccessMode
subjects?: Pick<Subject, 'subjectId' | 'subjectType'>[]
subjects?: Pick<EnterpriseSubject, 'subjectId' | 'subjectType'>[]
} = { appId: app.id, accessMode: currentMenu }
if (currentMenu === AccessMode.SPECIFIC_GROUPS_MEMBERS) {
const subjects: Pick<Subject, 'subjectId' | 'subjectType'>[] = []
const subjects: Pick<EnterpriseSubject, 'subjectId' | 'subjectType'>[] = []
specificGroups.forEach((group) => {
subjects.push({ subjectId: group.id, subjectType: SubjectType.GROUP })
subjects.push({ subjectId: group.id, subjectType: EnterpriseSubjectType.SUBJECT_TYPE_GROUP })
})
specificMembers.forEach((member) => {
subjects.push({
subjectId: member.id,
subjectType: SubjectType.ACCOUNT,
subjectType: EnterpriseSubjectType.SUBJECT_TYPE_ACCOUNT,
})
})
submitData.subjects = subjects
}
await updateAccessMode(submitData)
toast.success(t('accessControlDialog.updateSuccess', { ns: 'app' }))
onConfirm?.()
}, [updateAccessMode, app, specificGroups, specificMembers, t, onConfirm, currentMenu])
updateAccessMode({
body: submitData,
}, {
onSuccess: () => {
toast.success(successMessage)
onConfirm?.()
},
})
}
return (
<AccessControlDialog show onClose={onClose}>
<div className="flex flex-col gap-y-3">
<div className="pt-6 pr-14 pb-3 pl-6">
<DialogTitle className="title-2xl-semi-bold text-text-primary">{t('accessControlDialog.title', { ns: 'app' })}</DialogTitle>
<DialogDescription className="mt-1 system-xs-regular text-text-tertiary">{t('accessControlDialog.description', { ns: 'app' })}</DialogDescription>
</div>
<div className="flex flex-col gap-y-1 px-6 pb-3">
<div className="leading-6">
<p className="system-sm-medium text-text-tertiary">{t('accessControlDialog.accessLabel', { ns: 'app' })}</p>
</div>
<AccessControlItem type={AccessMode.ORGANIZATION}>
<div className="flex items-center p-3">
<div className="flex grow items-center gap-x-2">
<RiBuildingLine className="size-4 text-text-primary" />
<p className="system-sm-medium text-text-primary">{t('accessControlDialog.accessItems.organization', { ns: 'app' })}</p>
</div>
</div>
</AccessControlItem>
<AccessControlItem type={AccessMode.SPECIFIC_GROUPS_MEMBERS}>
<SpecificGroupsOrMembers />
</AccessControlItem>
<AccessControlItem type={AccessMode.EXTERNAL_MEMBERS}>
<div className="flex items-center p-3">
<div className="flex grow items-center gap-x-2">
<RiVerifiedBadgeLine className="size-4 text-text-primary" />
<p className="system-sm-medium text-text-primary">{t('accessControlDialog.accessItems.external', { ns: 'app' })}</p>
</div>
{!hideTip && <WebAppSSONotEnabledTip />}
</div>
</AccessControlItem>
<AccessControlItem type={AccessMode.PUBLIC}>
<div className="flex items-center gap-x-2 p-3">
<RiGlobalLine className="size-4 text-text-primary" />
<p className="system-sm-medium text-text-primary">{t('accessControlDialog.accessItems.anyone', { ns: 'app' })}</p>
</div>
</AccessControlItem>
</div>
<div className="flex items-center justify-end gap-x-2 p-6 pt-5">
<Button onClick={onClose}>{t('operation.cancel', { ns: 'common' })}</Button>
<Button disabled={isPending} loading={isPending} variant="primary" onClick={handleConfirm}>{t('operation.confirm', { ns: 'common' })}</Button>
</div>
</div>
<AccessControlDialogContent
hideExternalTip={hideExternalTip}
saving={isPending}
controlsDisabled={subjectsLoading || isPending}
confirmDisabled={subjectsLoading}
specificGroupsOrMembersProps={{
loading: subjectsLoading,
}}
onClose={onClose}
onConfirm={handleConfirm}
/>
</AccessControlDialog>
)
}

View File

@ -1,34 +1,30 @@
'use client'
import type { AccessControlAccount, AccessControlGroup } from '@/models/access-control'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { RiCloseCircleFill, RiLockLine, RiOrganizationChart } from '@remixicon/react'
import { useCallback, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { AccessMode } from '@/models/access-control'
import { useAppWhiteListSubjects } from '@/service/access-control'
import useAccessControlStore from '../../../../context/access-control-store'
import { Infotip } from '../../base/infotip'
import Loading from '../../base/loading'
import AddMemberOrGroupDialog from './add-member-or-group-pop'
import { AccessSubjectSelectionList } from './access-subject-selector/selection-list'
import { AddMemberOrGroupDialog } from './add-member-or-group-pop'
import { useAccessControlStore } from './store'
export default function SpecificGroupsOrMembers() {
export type SpecificGroupsOrMembersProps = {
loading?: boolean
}
export function SpecificGroupsOrMembers({
loading = false,
}: SpecificGroupsOrMembersProps) {
const currentMenu = useAccessControlStore(s => s.currentMenu)
const appId = useAccessControlStore(s => s.appId)
const specificGroups = useAccessControlStore(s => s.specificGroups)
const setSpecificGroups = useAccessControlStore(s => s.setSpecificGroups)
const specificMembers = useAccessControlStore(s => s.specificMembers)
const setSpecificMembers = useAccessControlStore(s => s.setSpecificMembers)
const { t } = useTranslation()
const { isPending, data } = useAppWhiteListSubjects(appId, Boolean(appId) && currentMenu === AccessMode.SPECIFIC_GROUPS_MEMBERS)
useEffect(() => {
setSpecificGroups(data?.groups ?? [])
setSpecificMembers(data?.members ?? [])
}, [data, setSpecificGroups, setSpecificMembers])
if (currentMenu !== AccessMode.SPECIFIC_GROUPS_MEMBERS) {
return (
<div className="flex items-center p-3">
<div className="flex grow items-center gap-x-2">
<RiLockLine className="size-4 text-text-primary" />
<span className="i-ri-lock-line size-4 text-text-primary" aria-hidden="true" />
<p className="system-sm-medium text-text-primary">{t('accessControlDialog.accessItems.specific', { ns: 'app' })}</p>
</div>
</div>
@ -39,109 +35,28 @@ export default function SpecificGroupsOrMembers() {
<div>
<div className="flex items-center gap-x-1 p-3">
<div className="flex grow items-center gap-x-1">
<RiLockLine className="size-4 text-text-primary" />
<span className="i-ri-lock-line size-4 text-text-primary" aria-hidden="true" />
<p className="system-sm-medium text-text-primary">{t('accessControlDialog.accessItems.specific', { ns: 'app' })}</p>
</div>
<div className="flex items-center gap-x-1">
<AddMemberOrGroupDialog />
<AddMemberOrGroupDialog disabled={loading} />
</div>
</div>
<div className="px-1 pb-1">
<div className="flex max-h-[400px] flex-col gap-y-2 overflow-y-auto rounded-lg bg-background-section p-2">
{isPending ? <Loading /> : <RenderGroupsAndMembers />}
</div>
<AccessSubjectSelectionList
selectedGroups={specificGroups}
selectedMembers={specificMembers}
loading={loading}
onChange={({ groups, members }) => {
setSpecificGroups(groups)
setSpecificMembers(members)
}}
/>
</div>
</div>
)
}
function RenderGroupsAndMembers() {
const { t } = useTranslation()
const specificGroups = useAccessControlStore(s => s.specificGroups)
const specificMembers = useAccessControlStore(s => s.specificMembers)
if (specificGroups.length <= 0 && specificMembers.length <= 0)
return <div className="px-2 pt-5 pb-1.5"><p className="text-center system-xs-regular text-text-tertiary">{t('accessControlDialog.noGroupsOrMembers', { ns: 'app' })}</p></div>
return (
<>
<p className="sticky top-0 system-2xs-medium-uppercase text-text-tertiary">{t('accessControlDialog.groups', { ns: 'app', count: specificGroups.length ?? 0 })}</p>
<div className="flex flex-row flex-wrap gap-1">
{specificGroups.map((group, index) => <GroupItem key={index} group={group} />)}
</div>
<p className="sticky top-0 system-2xs-medium-uppercase text-text-tertiary">{t('accessControlDialog.members', { ns: 'app', count: specificMembers.length ?? 0 })}</p>
<div className="flex flex-row flex-wrap gap-1">
{specificMembers.map((member, index) => <MemberItem key={index} member={member} />)}
</div>
</>
)
}
type GroupItemProps = {
group: AccessControlGroup
}
function GroupItem({ group }: GroupItemProps) {
const specificGroups = useAccessControlStore(s => s.specificGroups)
const setSpecificGroups = useAccessControlStore(s => s.setSpecificGroups)
const handleRemoveGroup = useCallback(() => {
setSpecificGroups(specificGroups.filter(g => g.id !== group.id))
}, [group, setSpecificGroups, specificGroups])
return (
<BaseItem
icon={<RiOrganizationChart className="h-[14px] w-[14px] text-components-avatar-shape-fill-stop-0" />}
onRemove={handleRemoveGroup}
>
<p className="system-xs-regular text-text-primary">{group.name}</p>
<p className="system-xs-regular text-text-tertiary">{group.groupSize}</p>
</BaseItem>
)
}
type MemberItemProps = {
member: AccessControlAccount
}
function MemberItem({ member }: MemberItemProps) {
const specificMembers = useAccessControlStore(s => s.specificMembers)
const setSpecificMembers = useAccessControlStore(s => s.setSpecificMembers)
const handleRemoveMember = useCallback(() => {
setSpecificMembers(specificMembers.filter(m => m.id !== member.id))
}, [member, setSpecificMembers, specificMembers])
return (
<BaseItem
icon={<Avatar size="xxs" avatar={null} name={member.name} />}
onRemove={handleRemoveMember}
>
<p className="system-xs-regular text-text-primary">{member.name}</p>
</BaseItem>
)
}
type BaseItemProps = {
icon: React.ReactNode
children: React.ReactNode
onRemove?: () => void
}
function BaseItem({ icon, onRemove, children }: BaseItemProps) {
const { t } = useTranslation()
return (
<div className="group flex flex-row items-center gap-x-1 rounded-full border-[0.5px] border-components-panel-border-subtle bg-components-badge-white-to-dark p-1 pr-1.5 shadow-xs">
<div className="size-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid">
<div className="bg-access-app-icon-mask-bg flex size-full items-center justify-center">
{icon}
</div>
</div>
{children}
<button
type="button"
className="flex size-4 cursor-pointer items-center justify-center border-none bg-transparent p-0 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
aria-label={t('operation.remove', { ns: 'common' })}
onClick={onRemove}
>
<RiCloseCircleFill className="h-[14px] w-[14px] text-text-quaternary" aria-hidden="true" />
</button>
</div>
)
}
export function WebAppSSONotEnabledTip() {
const { t } = useTranslation()
const tip = t('accessControlDialog.webAppSSONotEnabledTip', { ns: 'app' })

View File

@ -0,0 +1,34 @@
'use client'
import type { ReactNode } from 'react'
import type { AccessControlDraft, AccessControlStoreApi } from './store'
import { useRef } from 'react'
import { AccessControlStoreContext, createAccessControlStore } from './store'
export function AccessControlDraftProvider({
children,
draftKey,
initialDraft,
}: {
children?: ReactNode
draftKey: string
initialDraft: AccessControlDraft
}) {
const storeRef = useRef<{
draftKey: string
store: AccessControlStoreApi
} | undefined>(undefined)
if (!storeRef.current || storeRef.current.draftKey !== draftKey) {
storeRef.current = {
draftKey,
store: createAccessControlStore(initialDraft),
}
}
return (
<AccessControlStoreContext value={storeRef.current.store}>
{children}
</AccessControlStoreContext>
)
}

View File

@ -0,0 +1,52 @@
import type { StoreApi } from 'zustand'
import type { AccessControlAccount, AccessControlGroup, AccessMode } from '@/models/access-control'
import type { App } from '@/types/app'
import { createContext, use } from 'react'
import { useStore } from 'zustand'
import { createStore } from 'zustand/vanilla'
export type AccessControlDraft = {
appId?: App['id']
currentMenu: AccessMode
specificGroups?: AccessControlGroup[]
specificMembers?: AccessControlAccount[]
selectedGroupsForBreadcrumb?: AccessControlGroup[]
}
export type AccessControlStore = {
appId: App['id']
specificGroups: AccessControlGroup[]
setSpecificGroups: (specificGroups: AccessControlGroup[]) => void
specificMembers: AccessControlAccount[]
setSpecificMembers: (specificMembers: AccessControlAccount[]) => void
currentMenu: AccessMode
setCurrentMenu: (currentMenu: AccessMode) => void
selectedGroupsForBreadcrumb: AccessControlGroup[]
setSelectedGroupsForBreadcrumb: (selectedGroupsForBreadcrumb: AccessControlGroup[]) => void
}
export type AccessControlStoreApi = StoreApi<AccessControlStore>
export function createAccessControlStore(initialDraft: AccessControlDraft) {
return createStore<AccessControlStore>(set => ({
appId: initialDraft.appId ?? '',
specificGroups: initialDraft.specificGroups ?? [],
setSpecificGroups: specificGroups => set({ specificGroups }),
specificMembers: initialDraft.specificMembers ?? [],
setSpecificMembers: specificMembers => set({ specificMembers }),
currentMenu: initialDraft.currentMenu,
setCurrentMenu: currentMenu => set({ currentMenu }),
selectedGroupsForBreadcrumb: initialDraft.selectedGroupsForBreadcrumb ?? [],
setSelectedGroupsForBreadcrumb: selectedGroupsForBreadcrumb => set({ selectedGroupsForBreadcrumb }),
}))
}
export const AccessControlStoreContext = createContext<AccessControlStoreApi | undefined>(undefined)
export function useAccessControlStore<T>(selector: (state: AccessControlStore) => T) {
const store = use(AccessControlStoreContext)
if (!store)
throw new Error('useAccessControlStore must be used inside AccessControlDraftProvider')
return useStore(store, selector)
}

View File

@ -39,7 +39,7 @@ vi.mock('react-i18next', () => ({
}))
vi.mock('@/app/components/app/app-publisher', () => ({
default: (props: Record<string, any>) => {
AppPublisher: (props: Record<string, any>) => {
mockAppPublisherProps.current = props
return (
<div>

View File

@ -5,7 +5,7 @@ import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features
import { AccessMode } from '@/models/access-control'
import { AppModeEnum } from '@/types/app'
import { basePath } from '@/utils/var'
import AppPublisher from '../index'
import { AppPublisher } from '../index'
const render = (ui: React.ReactElement) => renderWithSystemFeatures(ui, {
systemFeatures: { webapp_auth: { enabled: true } },
@ -16,6 +16,7 @@ const mockOnToggle = vi.fn()
const mockSetAppDetail = vi.fn()
const mockTrackEvent = vi.fn()
const mockRefetch = vi.fn()
const mockUseGetUserCanAccessApp = vi.fn()
const mockOpenAsyncWindow = vi.fn()
const mockFetchInstalledAppList = vi.fn()
const mockFetchAppDetailDirect = vi.fn()
@ -65,11 +66,14 @@ vi.mock('@/hooks/use-async-window-open', () => ({
}))
vi.mock('@/service/access-control', () => ({
useGetUserCanAccessApp: () => ({
data: { result: true },
isLoading: false,
refetch: mockRefetch,
}),
useGetUserCanAccessApp: (params: unknown) => {
mockUseGetUserCanAccessApp(params)
return {
data: { result: true },
isLoading: false,
refetch: mockRefetch,
}
},
useAppWhiteListSubjects: () => ({
data: { groups: [], members: [] },
isLoading: false,
@ -128,7 +132,7 @@ vi.mock('@/app/components/app/overview/embedded', () => ({
}))
vi.mock('../../app-access-control', () => ({
default: ({ onConfirm, onClose }: { onConfirm: () => Promise<void>, onClose: () => void }) => (
AccessControl: ({ onConfirm, onClose }: { onConfirm: () => Promise<void>, onClose: () => void }) => (
<div data-testid="access-control">
<button onClick={() => void onConfirm()}>confirm-access-control</button>
<button onClick={onClose}>close-access-control</button>
@ -212,7 +216,7 @@ describe('AppPublisher', () => {
})
})
it('should open the publish popover and refetch access permission data', async () => {
it('should enable access permission query when the publish popover opens', async () => {
render(
<AppPublisher
publishedAt={Date.now()}
@ -226,8 +230,12 @@ describe('AppPublisher', () => {
expect(mockOnToggle).toHaveBeenCalledWith(true)
await waitFor(() => {
expect(mockRefetch).toHaveBeenCalledTimes(1)
expect(mockUseGetUserCanAccessApp).toHaveBeenCalledWith({
appId: 'app-1',
enabled: true,
})
})
expect(mockRefetch).not.toHaveBeenCalled()
})
it('should publish and track the publish event', async () => {

View File

@ -14,7 +14,7 @@ import { produce } from 'immer'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppPublisher from '@/app/components/app/app-publisher'
import { AppPublisher } from '@/app/components/app/app-publisher'
import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks'
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'

View File

@ -10,12 +10,8 @@ import { toast } from '@langgenius/dify-ui/toast'
import { useHotkey } from '@tanstack/react-hotkeys'
import { useSuspenseQuery } from '@tanstack/react-query'
import {
memo,
use,
useCallback,
useEffect,
useMemo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
@ -47,7 +43,7 @@ import { useInvalidateAppWorkflow } from '@/service/use-workflow'
import { fetchPublishedWorkflow } from '@/service/workflow'
import { AppModeEnum } from '@/types/app'
import { basePath } from '@/utils/var'
import AccessControl from '../app-access-control'
import { AccessControl } from '../app-access-control'
import {
PublisherAccessSection,
PublisherActionsSection,
@ -94,7 +90,7 @@ type AppPublisherPublishHandler
type AppPublisherRestoreHandler = () => Promise<unknown> | unknown
const AppPublisher = ({
export function AppPublisher({
disabled = false,
publishDisabled = false,
publishedAt,
@ -114,7 +110,7 @@ const AppPublisher = ({
hasTriggerNode = false,
startNodeLimitExceeded = false,
hasHumanInputNode = false,
}: AppPublisherProps) => {
}: AppPublisherProps) {
const { t } = useTranslation()
const [published, setPublished] = useState(false)
@ -137,50 +133,37 @@ const AppPublisher = ({
const appURL = getPublisherAppUrl({ appBaseUrl: appBaseURL, accessToken, mode: appDetail?.mode })
const isChatApp = [AppModeEnum.CHAT, AppModeEnum.AGENT_CHAT, AppModeEnum.COMPLETION].includes(appDetail?.mode || AppModeEnum.CHAT)
const hiddenLaunchVariables = useMemo<WorkflowHiddenStartVariable[]>(
() => (inputs ?? []).filter(input => input.hide === true),
[inputs],
)
const supportedWorkflowLaunchVariables = useMemo(
() => hiddenLaunchVariables.filter(isWorkflowLaunchInputSupported),
[hiddenLaunchVariables],
)
const unsupportedWorkflowLaunchVariables = useMemo(
() => hiddenLaunchVariables.filter(variable => !isWorkflowLaunchInputSupported(variable)),
[hiddenLaunchVariables],
)
const initialWorkflowLaunchValues = useMemo(
() => createWorkflowLaunchInitialValues(supportedWorkflowLaunchVariables),
[supportedWorkflowLaunchVariables],
)
const hiddenLaunchVariables: WorkflowHiddenStartVariable[] = (inputs ?? []).filter(input => input.hide === true)
const supportedWorkflowLaunchVariables = hiddenLaunchVariables.filter(isWorkflowLaunchInputSupported)
const unsupportedWorkflowLaunchVariables = hiddenLaunchVariables.filter(variable => !isWorkflowLaunchInputSupported(variable))
const initialWorkflowLaunchValues = createWorkflowLaunchInitialValues(supportedWorkflowLaunchVariables)
const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp, refetch } = useGetUserCanAccessApp({ appId: appDetail?.id, enabled: false })
const shouldLoadUserCanAccessApp = Boolean(appDetail?.id && open && systemFeatures.webapp_auth.enabled)
const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp } = useGetUserCanAccessApp({
appId: appDetail?.id,
enabled: shouldLoadUserCanAccessApp,
})
const { data: appAccessSubjects, isLoading: isGettingAppWhiteListSubjects } = useAppWhiteListSubjects(appDetail?.id, open && systemFeatures.webapp_auth.enabled && appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS)
const invalidateAppWorkflow = useInvalidateAppWorkflow()
const openAsyncWindow = useAsyncWindowOpen()
const isAppAccessSet = useMemo(() => isPublisherAccessConfigured(appDetail, appAccessSubjects), [appAccessSubjects, appDetail])
const isAppAccessSet = isPublisherAccessConfigured(appDetail, appAccessSubjects)
const noAccessPermission = useMemo(() => Boolean(
const noAccessPermission = Boolean(
systemFeatures.webapp_auth.enabled
&& appDetail
&& appDetail.access_mode !== AccessMode.EXTERNAL_MEMBERS
&& !userCanAccessApp?.result,
), [systemFeatures, appDetail, userCanAccessApp])
const disabledFunctionButton = useMemo(() => (!publishedAt || missingStartNode || noAccessPermission), [publishedAt, missingStartNode, noAccessPermission])
const disabledFunctionTooltip = useMemo(() => getDisabledFunctionTooltip({
)
const disabledFunctionButton = !publishedAt || missingStartNode || noAccessPermission
const disabledFunctionTooltip = getDisabledFunctionTooltip({
t,
publishedAt,
missingStartNode,
noAccessPermission,
}), [missingStartNode, noAccessPermission, publishedAt, t])
})
useEffect(() => {
if (systemFeatures.webapp_auth.enabled && open && appDetail)
refetch()
}, [open, appDetail, refetch, systemFeatures])
const handlePublish = useCallback(async (params?: ModelAndParameter | PublishWorkflowParams) => {
async function handlePublish(params?: ModelAndParameter | PublishWorkflowParams) {
try {
await onPublish?.(params)
setPublished(true)
@ -212,17 +195,17 @@ const AppPublisher = ({
console.warn('[app-publisher] publish failed', error)
setPublished(false)
}
}, [appDetail, onPublish, invalidateAppWorkflow])
}
const handleRestore = useCallback(async () => {
async function handleRestore() {
try {
await onRestore?.()
setOpen(false)
}
catch { }
}, [onRestore])
}
const handleOpenChange = useCallback((nextOpen: boolean) => {
function handleOpenChange(nextOpen: boolean) {
if (disabled) {
setOpen(false)
return
@ -233,9 +216,9 @@ const AppPublisher = ({
if (nextOpen)
setPublished(false)
}, [disabled, onToggle])
}
const handleOpenInExplore = useCallback(async () => {
async function handleOpenInExplore() {
await openAsyncWindow(async () => {
if (!appDetail?.id)
throw new Error('App not found')
@ -248,9 +231,9 @@ const AppPublisher = ({
toast.error(`${err.message || err}`)
},
})
}, [appDetail?.id, openAsyncWindow])
}
const handleAccessControlUpdate = useCallback(async () => {
async function handleAccessControlUpdate() {
if (!appDetail)
return
try {
@ -260,22 +243,22 @@ const AppPublisher = ({
finally {
setShowAppAccessControl(false)
}
}, [appDetail, setAppDetail])
}
const handleOpenWorkflowLaunchDialog = useCallback((targetUrl: string) => {
function handleOpenWorkflowLaunchDialog(targetUrl: string) {
setWorkflowLaunchValues(initialWorkflowLaunchValues)
setWorkflowLaunchTargetUrl(targetUrl)
setWorkflowLaunchDialogOpen(true)
}, [initialWorkflowLaunchValues])
}
const handleWorkflowLaunchValueChange = useCallback((variable: string, value: WorkflowLaunchInputValue) => {
function handleWorkflowLaunchValueChange(variable: string, value: WorkflowLaunchInputValue) {
setWorkflowLaunchValues(prev => ({
...prev,
[variable]: value,
}))
}, [])
}
const handleWorkflowLaunchConfirm = useCallback(async (event: FormEvent<HTMLFormElement>) => {
async function handleWorkflowLaunchConfirm(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const targetUrl = await buildWorkflowLaunchUrl({
@ -286,8 +269,9 @@ const AppPublisher = ({
window.open(targetUrl, '_blank')
setWorkflowLaunchDialogOpen(false)
}, [supportedWorkflowLaunchVariables, workflowLaunchTargetUrl, workflowLaunchValues])
const handlePublishToMarketplace = useCallback(async () => {
}
async function handlePublishToMarketplace() {
if (!appDetail?.id || publishingToMarketplace)
return
setPublishingToMarketplace(true)
@ -302,7 +286,7 @@ const AppPublisher = ({
finally {
setPublishingToMarketplace(false)
}
}, [appDetail?.id, publishingToMarketplace, t])
}
useHotkey('Mod+Shift+P', (e) => {
e.preventDefault()
@ -340,11 +324,13 @@ const AppPublisher = ({
: undefined
const workflowToolVisible = appDetail?.mode === AppModeEnum.WORKFLOW && !hasHumanInputNode && !hasTriggerNode
const workflowToolPublished = !!toolPublished
const closeWorkflowToolDrawer = useCallback(() => setWorkflowToolDrawerOpen(false), [])
const workflowToolIcon = useMemo(() => ({
function closeWorkflowToolDrawer() {
setWorkflowToolDrawerOpen(false)
}
const workflowToolIcon = {
content: (appDetail?.icon_type === 'image' ? '🤖' : appDetail?.icon) || '🤖',
background: (appDetail?.icon_type === 'image' ? appDefaultIconBackground : appDetail?.icon_background) || appDefaultIconBackground,
}), [appDetail?.icon, appDetail?.icon_background, appDetail?.icon_type])
}
const workflowTool = useConfigureButton({
enabled: workflowToolVisible,
published: workflowToolPublished,
@ -359,16 +345,16 @@ const AppPublisher = ({
onRefreshData,
onConfigured: closeWorkflowToolDrawer,
})
const openWorkflowToolDrawer = useCallback(() => {
function openWorkflowToolDrawer() {
handleOpenChange(false)
setWorkflowToolDrawerOpen(true)
}, [handleOpenChange])
const upgradeHighlightStyle = useMemo(() => ({
}
const upgradeHighlightStyle = {
background: 'linear-gradient(97deg, var(--components-input-border-active-prompt-1, rgba(11, 165, 236, 0.95)) -3.64%, var(--components-input-border-active-prompt-2, rgba(21, 90, 239, 0.95)) 45.14%)',
WebkitBackgroundClip: 'text',
backgroundClip: 'text',
WebkitTextFillColor: 'transparent',
}), [])
}
return (
<>
@ -497,5 +483,3 @@ const AppPublisher = ({
</>
)
}
export default memo(AppPublisher)

View File

@ -26,7 +26,7 @@ vi.mock('../customize', () => ({
}))
vi.mock('../../app-access-control', () => ({
default: ({ onClose, onConfirm }: { onClose: () => void, onConfirm: () => void }) => (
AccessControl: ({ onClose, onConfirm }: { onClose: () => void, onConfirm: () => void }) => (
<div data-testid="access-control">
<button type="button" onClick={onClose}>close-access</button>
<button type="button" onClick={onConfirm}>confirm-access</button>

View File

@ -90,7 +90,7 @@ vi.mock('../customize', () => ({
}))
vi.mock('../../app-access-control', () => ({
default: ({ onConfirm, onClose }: { onConfirm: () => Promise<void>, onClose: () => void }) => (
AccessControl: ({ onConfirm, onClose }: { onConfirm: () => Promise<void>, onClose: () => void }) => (
<div data-testid="access-control-modal">
<button onClick={() => void onConfirm()}>confirm-access-control</button>
<button onClick={onClose}>close-access-control</button>

View File

@ -37,7 +37,7 @@ import Divider from '@/app/components/base/divider'
import ShareQRCode from '@/app/components/base/qrcode'
import { AccessMode } from '@/models/access-control'
import { AppModeEnum } from '@/types/app'
import AccessControl from '../app-access-control'
import { AccessControl } from '../app-access-control'
import CustomizeModal from './customize'
import EmbeddedModal from './embedded'
import SettingsModal from './settings'

View File

@ -8,7 +8,7 @@ import * as appsService from '@/service/apps'
import * as exploreService from '@/service/explore'
import * as workflowService from '@/service/workflow'
import { AppModeEnum } from '@/types/app'
import AppCard from '../app-card'
import { AppCard } from '../app-card'
let mockWebappAuthEnabled = false
@ -1731,7 +1731,7 @@ describe('AppCard', () => {
vi.resetModules()
vi.doMock('@langgenius/dify-ui/alert-dialog', createMockAlertDialogModule)
const { default: IsolatedAppCard } = await import('../app-card')
const { AppCard: IsolatedAppCard } = await import('../app-card')
render(<IsolatedAppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
@ -1753,7 +1753,7 @@ describe('AppCard', () => {
vi.resetModules()
vi.doMock('@langgenius/dify-ui/alert-dialog', createMockAlertDialogModule)
const { default: IsolatedAppCard } = await import('../app-card')
const { AppCard: IsolatedAppCard } = await import('../app-card')
render(<IsolatedAppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
@ -1773,7 +1773,7 @@ describe('AppCard', () => {
mockDeleteMutationPending = true
vi.doMock('@langgenius/dify-ui/alert-dialog', createMockAlertDialogModule)
const { default: IsolatedAppCard } = await import('../app-card')
const { AppCard: IsolatedAppCard } = await import('../app-card')
render(<IsolatedAppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))

View File

@ -276,6 +276,9 @@ vi.mock('@/next/dynamic', () => ({
}))
vi.mock('../app-card', () => ({
AppCard: ({ app }: { app: { id: string, name: string } }) => {
return React.createElement('div', { 'data-testid': `app-card-${app.id}`, 'role': 'article' }, app.name)
},
AppCardActionBar: ({ app, onRefresh }: { app: { id: string }, onRefresh?: () => void }) => {
return React.createElement('button', {
'data-testid': `app-card-action-bar-${app.id}`,

View File

@ -1,5 +1,6 @@
'use client'
import type { FormEvent, FormEventHandler, MouseEvent } from 'react'
import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-modal'
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
import type { EnvironmentVariable } from '@/app/components/workflow/types'
@ -31,7 +32,6 @@ import {
} from '@langgenius/dify-ui/tooltip'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useSetLocalStorage } from 'foxact/use-local-storage'
import * as React from 'react'
import { useCallback, useId, useMemo, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { AppTypeIcon } from '@/app/components/app/type-selector'
@ -72,7 +72,7 @@ const SwitchAppModal = dynamic(() => import('@/app/components/app/switch-app-mod
const DSLExportConfirmModal = dynamic(() => import('@/app/components/workflow/dsl-export-confirm-modal'), {
ssr: false,
})
const AccessControl = dynamic(() => import('@/app/components/app/app-access-control'), {
const AccessControl = dynamic(() => import('@/app/components/app/app-access-control').then(mod => mod.AccessControl), {
ssr: false,
})
@ -140,7 +140,7 @@ type AppCardOperationsMenuProps = {
onAccessControl: () => void
}
const AppCardOperationsMenu: React.FC<AppCardOperationsMenuProps> = ({
function AppCardOperationsMenu({
app,
shouldShowSwitchOption,
shouldShowOpenInExploreOption,
@ -151,17 +151,17 @@ const AppCardOperationsMenu: React.FC<AppCardOperationsMenuProps> = ({
onSwitch,
onDelete,
onAccessControl,
}) => {
}: AppCardOperationsMenuProps) {
const { t } = useTranslation()
const openAsyncWindow = useAsyncWindowOpen()
const handleMenuAction = useCallback((e: React.MouseEvent<HTMLElement>, action: () => void) => {
function handleMenuAction(e: MouseEvent<HTMLElement>, action: () => void) {
e.stopPropagation()
e.preventDefault()
action()
}, [])
}
const handleOpenInstalledApp = useCallback(async (e: React.MouseEvent<HTMLElement>) => {
async function handleOpenInstalledApp(e: MouseEvent<HTMLElement>) {
e.stopPropagation()
e.preventDefault()
try {
@ -180,7 +180,7 @@ const AppCardOperationsMenu: React.FC<AppCardOperationsMenuProps> = ({
const message = e instanceof Error ? e.message : `${e}`
toast.error(message)
}
}, [app.id, openAsyncWindow])
}
return (
<>
@ -234,7 +234,7 @@ const AppCardOperationsMenu: React.FC<AppCardOperationsMenuProps> = ({
type AppCardOperationsMenuContentProps = Omit<AppCardOperationsMenuProps, 'shouldShowOpenInExploreOption'>
const AppCardOperationsMenuContent: React.FC<AppCardOperationsMenuContentProps> = (props) => {
function AppCardOperationsMenuContent(props: AppCardOperationsMenuContentProps) {
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp } = useGetUserCanAccessApp({
appId: props.app.id,
@ -260,7 +260,7 @@ type AppCardActionBarProps = {
onRefresh?: () => void
}
export const AppCardActionBar: React.FC<AppCardActionBarProps> = ({ app, onRefresh }) => {
export function AppCardActionBar({ app, onRefresh }: AppCardActionBarProps) {
const { t } = useTranslation()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { isCurrentWorkspaceEditor } = useAppContext()
@ -304,7 +304,7 @@ export const AppCardActionBar: React.FC<AppCardActionBarProps> = ({ app, onRefre
const isDeleteConfirmDisabled = isDeleting || confirmDeleteInput !== app.name
const onDeleteDialogSubmit: React.FormEventHandler<HTMLFormElement> = useCallback((e) => {
const onDeleteDialogSubmit: FormEventHandler<HTMLFormElement> = useCallback((e) => {
e.preventDefault()
if (isDeleteConfirmDisabled)
return
@ -442,7 +442,7 @@ export const AppCardActionBar: React.FC<AppCardActionBarProps> = ({ app, onRefre
setShowAccessControl(false)
}, [onRefresh, setShowAccessControl])
const handleToggleStar = useCallback(async (e: React.MouseEvent<HTMLButtonElement>) => {
const handleToggleStar = useCallback(async (e: MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
e.preventDefault()
@ -651,7 +651,7 @@ export const AppCardActionBar: React.FC<AppCardActionBarProps> = ({ app, onRefre
)
}
const AppCard = ({ app, onlineUsers = [], onRefresh, onOpenTagManagement = () => { } }: AppCardProps) => {
export function AppCard({ app, onlineUsers = [], onRefresh, onOpenTagManagement = () => {} }: AppCardProps) {
const { t } = useTranslation()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { isCurrentWorkspaceEditor } = useAppContext()
@ -670,7 +670,7 @@ const AppCard = ({ app, onlineUsers = [], onRefresh, onOpenTagManagement = () =>
const { mutateAsync: mutateToggleAppStar, isPending: isTogglingStar } = useToggleAppStarMutation()
const setNeedRefresh = useSetLocalStorage<string>(NEED_REFRESH_APP_LIST_KEY, { raw: true })
const onConfirmDelete = useCallback(async () => {
async function onConfirmDelete() {
try {
await mutateDeleteApp(app.id)
toast.success(t('appDeleted', { ns: 'app' }))
@ -682,63 +682,63 @@ const AppCard = ({ app, onlineUsers = [], onRefresh, onOpenTagManagement = () =>
const message = e instanceof Error ? e.message : ''
toast.error(`${t('appDeleteFailed', { ns: 'app' })}${message ? `: ${message}` : ''}`)
}
}, [app.id, mutateDeleteApp, onPlanInfoChanged, t])
}
const onDeleteDialogOpenChange = useCallback((open: boolean) => {
function onDeleteDialogOpenChange(open: boolean) {
if (isDeleting)
return
setShowConfirmDelete(open)
if (!open)
setConfirmDeleteInput('')
}, [isDeleting])
}
const isDeleteConfirmDisabled = isDeleting || confirmDeleteInput !== app.name
const onDeleteDialogSubmit: React.FormEventHandler<HTMLFormElement> = useCallback((e) => {
e.preventDefault()
function onDeleteDialogSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
if (isDeleteConfirmDisabled)
return
void onConfirmDelete()
}, [isDeleteConfirmDisabled, onConfirmDelete])
}
const handleShowEditModal = useCallback(() => {
function handleShowEditModal() {
setIsOperationsMenuOpen(false)
queueMicrotask(() => {
setShowEditModal(true)
})
}, [])
}
const handleShowDuplicateModal = useCallback(() => {
function handleShowDuplicateModal() {
setIsOperationsMenuOpen(false)
queueMicrotask(() => {
setShowDuplicateModal(true)
})
}, [])
}
const handleShowSwitchModal = useCallback(() => {
function handleShowSwitchModal() {
setIsOperationsMenuOpen(false)
queueMicrotask(() => {
setShowSwitchModal(true)
})
}, [])
}
const handleShowDeleteConfirm = useCallback(() => {
function handleShowDeleteConfirm() {
setIsOperationsMenuOpen(false)
queueMicrotask(() => {
setShowConfirmDelete(true)
})
}, [])
}
const handleShowAccessControl = useCallback(() => {
function handleShowAccessControl() {
setIsOperationsMenuOpen(false)
queueMicrotask(() => {
setShowAccessControl(true)
})
}, [])
}
const onEdit: CreateAppModalProps['onConfirm'] = useCallback(async ({
const onEdit: CreateAppModalProps['onConfirm'] = async ({
name,
icon_type,
icon,
@ -766,7 +766,7 @@ const AppCard = ({ app, onlineUsers = [], onRefresh, onOpenTagManagement = () =>
catch (e) {
toast.error(e instanceof Error ? e.message : t('editFailed', { ns: 'app' }))
}
}, [app.id, onRefresh, t])
}
const onCopy: DuplicateAppModalProps['onConfirm'] = async ({ name, icon_type, icon, icon_background }) => {
try {
@ -831,11 +831,11 @@ const AppCard = ({ app, onlineUsers = [], onRefresh, onOpenTagManagement = () =>
setShowSwitchModal(false)
}
const onUpdateAccessControl = useCallback(() => {
function onUpdateAccessControl() {
if (onRefresh)
onRefresh()
setShowAccessControl(false)
}, [onRefresh, setShowAccessControl])
}
const handleToggleStar = useCallback(async (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
@ -1163,5 +1163,3 @@ const AppCard = ({ app, onlineUsers = [], onRefresh, onOpenTagManagement = () =>
</>
)
}
export default React.memo(AppCard)

View File

@ -15,7 +15,7 @@ import { CheckModal } from '@/hooks/use-pay'
import { usePathname, useRouter, useSearchParams } from '@/next/navigation'
import { consoleQuery } from '@/service/client'
import { AppModeEnum } from '@/types/app'
import AppCard from './app-card'
import { AppCard } from './app-card'
import { AppCardSkeleton } from './app-card-skeleton'
import { AppListCreationModals } from './app-list-creation-modals'
import { AppListHeaderFilters } from './app-list-header-filters'

View File

@ -2,7 +2,7 @@ import type { AnchorHTMLAttributes, ReactElement } from 'react'
import { fireEvent, screen } from '@testing-library/react'
import { vi } from 'vitest'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import Header from '../index'
import { Header } from '../index'
function createMockComponent(testId: string) {
return () => <div data-testid={testId} />
@ -21,7 +21,7 @@ vi.mock('@/app/components/header/app-nav', () => ({
}))
vi.mock('@/app/components/header/dataset-nav', () => ({
default: createMockComponent('dataset-nav'),
DatasetNav: createMockComponent('dataset-nav'),
}))
vi.mock('@/app/components/header/env-nav', () => ({
@ -29,7 +29,7 @@ vi.mock('@/app/components/header/env-nav', () => ({
}))
vi.mock('@/app/components/header/explore-nav', () => ({
default: createMockComponent('explore-nav'),
ExploreNav: createMockComponent('explore-nav'),
}))
vi.mock('@/app/components/header/license-env', () => ({
@ -41,7 +41,7 @@ vi.mock('@/app/components/header/plugins-nav', () => ({
}))
vi.mock('@/app/components/header/tools-nav', () => ({
default: createMockComponent('tools-nav'),
ToolsNav: createMockComponent('tools-nav'),
}))
vi.mock('@/app/components/header/plan-badge', () => ({

View File

@ -10,7 +10,7 @@ import {
useDatasetDetail,
useDatasetList,
} from '@/service/knowledge/use-dataset'
import DatasetNav from '../index'
import { DatasetNav } from '../index'
vi.mock('@/next/navigation', () => ({
useParams: vi.fn(),
@ -174,30 +174,34 @@ describe('DatasetNav', () => {
expect(within(menu).getByText('Null Icon Dataset')).toBeInTheDocument()
})
it('should navigate to correct link when an item is clicked', () => {
render(<DatasetNav />)
it('should navigate to correct links for navigation items', () => {
const { unmount } = render(<DatasetNav />)
const selector = screen.getByRole('button', { name: /Test Dataset/i })
fireEvent.click(selector)
const menu = screen.getByRole('menu')
const pipelineItem = within(menu).getByText('Pipeline Dataset')
fireEvent.click(pipelineItem)
// dataset-2 is rag_pipeline and not published -> /datasets/dataset-2/pipeline
fireEvent.click(pipelineItem)
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-2/pipeline')
fireEvent.click(selector)
const menu2 = screen.getByRole('menu')
const externalItem = within(menu2).getByText('External Dataset')
fireEvent.click(externalItem)
unmount()
mockPush.mockClear()
render(<DatasetNav />)
fireEvent.click(screen.getByRole('button', { name: /Test Dataset/i }))
const externalItem = within(screen.getByRole('menu')).getByText('External Dataset')
// dataset-3 is provider external -> /datasets/dataset-3/hitTesting
fireEvent.click(externalItem)
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-3/hitTesting')
fireEvent.click(selector)
const menu3 = screen.getByRole('menu')
const publishedItem = within(menu3).getByText('Published Pipeline')
fireEvent.click(publishedItem)
mockPush.mockClear()
fireEvent.click(screen.getByRole('button', { name: /Test Dataset/i }))
const publishedItem = within(screen.getByRole('menu')).getByText('Published Pipeline')
// dataset-4 is rag_pipeline and published -> /datasets/dataset-4/documents
fireEvent.click(publishedItem)
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-4/documents')
})
})

View File

@ -3,32 +3,71 @@
import type { NavItem } from '../nav/nav-selector'
import type { DataSet, IconInfo } from '@/models/datasets'
import { flatten } from 'es-toolkit/compat'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useParams, useRouter } from '@/next/navigation'
import { useDatasetDetail, useDatasetList } from '@/service/knowledge/use-dataset'
import { basePath } from '@/utils/var'
import Nav from '../nav'
const DEFAULT_DATASET_ICON: IconInfo = {
icon_type: 'emoji',
const DEFAULT_DATASET_ICON_INFO = {
icon: '📙',
icon_type: 'emoji',
icon_background: '#FFF4ED',
icon_url: '',
}
} satisfies IconInfo
type NullableDatasetIconInfo = Partial<{
[Key in keyof IconInfo]: IconInfo[Key] | null
}>
const normalizeDatasetIconInfo = (iconInfo?: NullableDatasetIconInfo | null): IconInfo => ({
icon_type: iconInfo?.icon_type ?? DEFAULT_DATASET_ICON.icon_type,
icon: iconInfo?.icon ?? DEFAULT_DATASET_ICON.icon,
icon_background: iconInfo?.icon_background ?? DEFAULT_DATASET_ICON.icon_background,
icon_url: iconInfo?.icon_url ?? DEFAULT_DATASET_ICON.icon_url,
})
function normalizeDatasetIconInfo(iconInfo?: NullableDatasetIconInfo | null): IconInfo {
return {
icon: iconInfo?.icon ?? DEFAULT_DATASET_ICON_INFO.icon,
icon_type: iconInfo?.icon_type ?? DEFAULT_DATASET_ICON_INFO.icon_type,
icon_background: iconInfo?.icon_background ?? DEFAULT_DATASET_ICON_INFO.icon_background,
icon_url: iconInfo?.icon_url ?? DEFAULT_DATASET_ICON_INFO.icon_url,
}
}
const DatasetNav = () => {
function datasetLink(dataset: DataSet) {
const isPipelineUnpublished = dataset.runtime_mode === 'rag_pipeline' && !dataset.is_published
const link = isPipelineUnpublished
? `/datasets/${dataset.id}/pipeline`
: `/datasets/${dataset.id}/documents`
return dataset.provider === 'external'
? `/datasets/${dataset.id}/hitTesting`
: link
}
function currentDatasetNavItem(dataset: DataSet) {
const iconInfo = normalizeDatasetIconInfo(dataset.icon_info)
return {
id: dataset.id,
name: dataset.name,
icon: iconInfo.icon,
icon_type: iconInfo.icon_type,
icon_background: iconInfo.icon_background ?? null,
icon_url: iconInfo.icon_url ?? null,
} satisfies Omit<NavItem, 'link'>
}
function datasetNavItem(dataset: DataSet) {
const iconInfo = normalizeDatasetIconInfo(dataset.icon_info)
return {
id: dataset.id,
name: dataset.name,
link: datasetLink(dataset),
icon: iconInfo.icon,
icon_type: iconInfo.icon_type,
icon_background: iconInfo.icon_background ?? null,
icon_url: iconInfo.icon_url ?? null,
} satisfies NavItem
}
export function DatasetNav() {
const { t } = useTranslation()
const router = useRouter()
const { datasetId } = useParams()
@ -44,64 +83,23 @@ const DatasetNav = () => {
})
const datasetItems = flatten(datasetList?.pages.map(datasetData => datasetData.data))
const curNav = useMemo(() => {
if (!currentDataset)
return
const iconInfo = normalizeDatasetIconInfo(currentDataset.icon_info)
return {
id: currentDataset.id,
name: currentDataset.name,
icon: iconInfo.icon,
icon_type: iconInfo.icon_type,
icon_background: iconInfo.icon_background ?? null,
icon_url: iconInfo.icon_url ?? null,
} as Omit<NavItem, 'link'>
}, [currentDataset])
const curNav = currentDataset ? currentDatasetNavItem(currentDataset) : undefined
const navigationItems = datasetItems.map(datasetNavItem)
const runtimeMode = currentDataset?.runtime_mode
const createRoute = runtimeMode === 'rag_pipeline'
? `${basePath}/datasets/create-from-pipeline`
: `${basePath}/datasets/create`
const getDatasetLink = useCallback((dataset: DataSet) => {
const isPipelineUnpublished = dataset.runtime_mode === 'rag_pipeline' && !dataset.is_published
const link = isPipelineUnpublished
? `/datasets/${dataset.id}/pipeline`
: `/datasets/${dataset.id}/documents`
return dataset.provider === 'external'
? `/datasets/${dataset.id}/hitTesting`
: link
}, [])
const navigationItems = useMemo(() => {
return datasetItems.map((dataset) => {
const link = getDatasetLink(dataset)
const iconInfo = normalizeDatasetIconInfo(dataset.icon_info)
return {
id: dataset.id,
name: dataset.name,
link,
icon: iconInfo.icon,
icon_type: iconInfo.icon_type,
icon_background: iconInfo.icon_background ?? null,
icon_url: iconInfo.icon_url ?? null,
}
}) as NavItem[]
}, [datasetItems, getDatasetLink])
const createRoute = useMemo(() => {
const runtimeMode = currentDataset?.runtime_mode
if (runtimeMode === 'rag_pipeline')
return `${basePath}/datasets/create-from-pipeline`
else
return `${basePath}/datasets/create`
}, [currentDataset?.runtime_mode])
const handleLoadMore = useCallback(() => {
if (hasNextPage)
fetchNextPage()
}, [hasNextPage, fetchNextPage])
function handleLoadMore() {
if (hasNextPage && !isFetchingNextPage)
void fetchNextPage()
}
return (
<Nav
isApp={false}
icon={<span className="i-ri-book-2-line size-4" />}
activeIcon={<span className="i-ri-book-2-fill size-4" />}
icon={<span aria-hidden className="i-ri-book-2-line size-4" />}
activeIcon={<span aria-hidden className="i-ri-book-2-fill size-4" />}
text={t('menus.datasets', { ns: 'common' })}
activeSegment="datasets"
link="/datasets"
@ -114,5 +112,3 @@ const DatasetNav = () => {
/>
)
}
export default DatasetNav

View File

@ -1,7 +1,7 @@
import type { Mock } from 'vitest'
import { render, screen } from '@testing-library/react'
import { usePathname, useSelectedLayoutSegment } from '@/next/navigation'
import ExploreNav from '../index'
import { ExploreNav } from '../index'
vi.mock('@/next/navigation', () => ({
usePathname: vi.fn(),

View File

@ -9,13 +9,11 @@ import { useTranslation } from 'react-i18next'
import Link from '@/next/link'
import { usePathname, useSelectedLayoutSegment } from '@/next/navigation'
type ExploreNavProps = {
className?: string
}
const ExploreNav = ({
export function ExploreNav({
className,
}: ExploreNavProps) => {
}: {
className?: string
}) {
const { t } = useTranslation()
const pathname = usePathname()
const selectedSegment = useSelectedLayoutSegment()
@ -31,11 +29,9 @@ const ExploreNav = ({
? <RiPlanetFill className="size-4" />
: <RiPlanetLine className="size-4" />
}
<div className="ml-2 max-[1024px]:hidden">
<div className="ml-2 max-[1120px]:hidden">
{t('menus.explore', { ns: 'common' })}
</div>
</Link>
)
}
export default ExploreNav

View File

@ -1,25 +1,25 @@
'use client'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useCallback } from 'react'
import DifyLogo from '@/app/components/base/logo/dify-logo'
import WorkplaceSelector from '@/app/components/header/account-dropdown/workplace-selector'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useAppContext } from '@/context/app-context'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { DeploymentsNav } from '@/features/deployments/nav'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import Link from '@/next/link'
import { Plan } from '../billing/type'
import AccountDropdown from './account-dropdown'
import AppNav from './app-nav'
import DatasetNav from './dataset-nav'
import { DatasetNav } from './dataset-nav'
import EnvNav from './env-nav'
import ExploreNav from './explore-nav'
import { ExploreNav } from './explore-nav'
import LicenseNav from './license-env'
import { PlanBadge } from './plan-badge'
import PluginsNav from './plugins-nav'
import ToolsNav from './tools-nav'
import { ToolsNav } from './tools-nav'
const navClassName = `
flex items-center relative px-3 h-8 rounded-xl
@ -27,7 +27,7 @@ const navClassName = `
cursor-pointer
`
const Header = () => {
export function Header() {
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator } = useAppContext()
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
@ -36,12 +36,14 @@ const Header = () => {
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const isFreePlan = plan.type === Plan.sandbox
const isBrandingEnabled = systemFeatures.branding.enabled
const handlePlanClick = useCallback(() => {
const canUseAppDeploy = isCurrentWorkspaceEditor && systemFeatures.enable_app_deploy
function handlePlanClick() {
if (isFreePlan)
setShowPricingModal()
else
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.BILLING })
}, [isFreePlan, setShowAccountSettingModal, setShowPricingModal])
}
const logoLabel = isBrandingEnabled && systemFeatures.branding.application_title ? systemFeatures.branding.application_title : 'Dify'
const renderLogo = () => (
@ -72,18 +74,17 @@ const Header = () => {
<WorkplaceSelector />
{enableBilling ? <PlanBadge allowHover sandboxAsUpgrade plan={plan.type} onClick={handlePlanClick} /> : <LicenseNav />}
</div>
<div className="flex items-center">
<div className="mr-2">
<PluginsNav />
</div>
<div className="flex items-center gap-2">
<PluginsNav />
<AccountDropdown />
</div>
</div>
<div className="my-1 flex items-center justify-center space-x-1">
<div className="my-1 flex items-center justify-center gap-1">
{!isCurrentWorkspaceDatasetOperator && <ExploreNav className={navClassName} />}
{!isCurrentWorkspaceDatasetOperator && <AppNav />}
{(isCurrentWorkspaceEditor || isCurrentWorkspaceDatasetOperator) && <DatasetNav />}
{!isCurrentWorkspaceDatasetOperator && <ToolsNav className={navClassName} />}
{canUseAppDeploy && <DeploymentsNav />}
</div>
</div>
)
@ -91,26 +92,24 @@ const Header = () => {
return (
<div className="flex h-[56px] items-center">
<div className="flex min-w-0 flex-1 items-center pr-2 pl-3 min-[1280px]:pr-3">
<div className="flex min-w-0 flex-1 items-center overflow-hidden pr-2 pl-3 min-[1280px]:pr-3">
{renderLogo()}
<div className="mx-1.5 shrink-0 font-light text-divider-deep">/</div>
<WorkplaceSelector />
{enableBilling ? <PlanBadge allowHover sandboxAsUpgrade plan={plan.type} onClick={handlePlanClick} /> : <LicenseNav />}
</div>
<div className="flex items-center space-x-2">
<div className="flex min-w-0 items-center justify-center gap-2 overflow-hidden py-3">
{!isCurrentWorkspaceDatasetOperator && <ExploreNav className={navClassName} />}
{!isCurrentWorkspaceDatasetOperator && <AppNav />}
{(isCurrentWorkspaceEditor || isCurrentWorkspaceDatasetOperator) && <DatasetNav />}
{!isCurrentWorkspaceDatasetOperator && <ToolsNav className={navClassName} />}
{canUseAppDeploy && <DeploymentsNav />}
</div>
<div className="flex min-w-0 flex-1 items-center justify-end pr-3 pl-2 min-[1280px]:pl-3">
<div className="flex min-w-0 flex-1 items-center justify-end gap-2 pr-3 pl-2 min-[1280px]:pl-3">
<EnvNav />
<div className="mr-2">
<PluginsNav />
</div>
<PluginsNav />
<AccountDropdown />
</div>
</div>
)
}
export default Header

View File

@ -12,14 +12,18 @@ import * as React from 'react'
import { vi } from 'vitest'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useAppContext } from '@/context/app-context'
import { useRouter, useSelectedLayoutSegment } from '@/next/navigation'
import { useSelectedLayoutSegment } from '@/next/navigation'
import { AppModeEnum } from '@/types/app'
import Nav from '../index'
const mockPush = vi.fn()
// Mock next/navigation
vi.mock('@/next/navigation', () => ({
useSelectedLayoutSegment: vi.fn(),
useRouter: vi.fn(),
useRouter: () => ({
push: mockPush,
}),
}))
// Mock app store
@ -56,7 +60,6 @@ describe('Nav Component', () => {
const mockSetAppDetail = vi.fn()
const mockOnCreate = vi.fn()
const mockOnLoadMore = vi.fn()
const mockPush = vi.fn()
const navigationItems: NavItem[] = [
{
@ -100,9 +103,6 @@ describe('Nav Component', () => {
vi.mocked(useAppContext).mockReturnValue({
isCurrentWorkspaceEditor: true,
} as unknown as AppContextValue)
vi.mocked(useRouter).mockReturnValue({
push: mockPush,
} as unknown as ReturnType<typeof useRouter>)
})
describe('Rendering', () => {
@ -139,9 +139,9 @@ describe('Nav Component', () => {
)
expect(screen.getByText('Nav Text')).toBeInTheDocument()
expect(screen.getByText('Nav Text')).toHaveClass('max-[1024px]:hidden')
expect(screen.getByText('Nav Text')).toHaveClass('max-[1120px]:hidden')
expect(screen.getByRole('link', { name: 'SNIPPETS' })).toHaveAttribute('href', '/snippets')
expect(screen.getByRole('link', { name: 'SNIPPETS' })).not.toHaveClass('max-[1024px]:hidden')
expect(screen.getByRole('link', { name: 'SNIPPETS' })).not.toHaveClass('max-[1120px]:hidden')
})
it('should not show hover background if not activated', () => {
@ -213,7 +213,7 @@ describe('Nav Component', () => {
})
})
it('should navigate when an item is selected', async () => {
it('should clear app detail and navigate when an item is selected', async () => {
vi.mocked(useSelectedLayoutSegment).mockReturnValue('snippets')
render(<Nav {...defaultProps} activeSegment={['apps', 'app', 'snippets']} curNav={curNav} />)
const selectorButton = screen.getByRole('button', { name: /Item 1/i })
@ -228,6 +228,7 @@ describe('Nav Component', () => {
fireEvent.click(item2)
})
expect(mockSetAppDetail).toHaveBeenCalled()
expect(mockPush).toHaveBeenCalledWith('/item2')
})
@ -248,7 +249,7 @@ describe('Nav Component', () => {
})
}
expect(mockPush).not.toHaveBeenCalled()
expect(mockSetAppDetail).not.toHaveBeenCalled()
})
it('should call onCreate when create button is clicked', async () => {

View File

@ -7,7 +7,7 @@ import { useState } from 'react'
import { useStore as useAppStore } from '@/app/components/app/store'
import Link from '@/next/link'
import { useSelectedLayoutSegment } from '@/next/navigation'
import NavSelector from './nav-selector'
import { NavSelector } from './nav-selector'
type INavProps = {
icon: React.ReactNode
@ -46,12 +46,12 @@ const Nav = ({
return (
<div className={`
flex h-8 max-w-167.5 shrink-0 items-center rounded-xl px-0.5 text-sm font-medium max-[1024px]:max-w-100
flex h-8 max-w-167.5 min-w-0 items-center rounded-xl px-0.5 text-sm font-medium max-[1120px]:max-w-100
${isActivated && 'bg-components-main-nav-nav-button-bg-active font-semibold shadow-md'}
${!curNav && !isActivated && 'hover:bg-components-main-nav-nav-button-bg-hover'}
`}
>
<Link href={link}>
<Link href={link} className="shrink-0">
<div
onClick={(e) => {
// Don't clear state if opening in new tab/window
@ -74,7 +74,7 @@ const Nav = ({
: icon
}
</div>
<div className="ml-2 max-[1024px]:hidden">
<div className="ml-2 max-[1120px]:hidden">
{text}
</div>
</div>
@ -82,7 +82,7 @@ const Nav = ({
{
curNav && isActivated && (
<>
<div className="font-light text-divider-deep">/</div>
<div className="shrink-0 font-light text-divider-deep">/</div>
<NavSelector
isApp={isApp}
curNav={curNav}
@ -98,12 +98,12 @@ const Nav = ({
{
!curNav && shouldShowActiveLink && (
<>
<div className="font-light text-divider-deep">/</div>
<div className="shrink-0 font-light text-divider-deep">/</div>
<Link
href={activeLink.link}
className="hover:bg-components-main-nav-nav-button-bg-active-hover flex h-7 cursor-pointer items-center rounded-[10px] px-2.5 text-components-main-nav-nav-button-text-active"
className="hover:bg-components-main-nav-nav-button-bg-active-hover flex h-7 min-w-0 cursor-pointer items-center rounded-[10px] px-2.5 text-components-main-nav-nav-button-text-active"
>
{activeLink.text}
<span className="truncate">{activeLink.text}</span>
</Link>
</>
)

View File

@ -5,13 +5,15 @@ import userEvent from '@testing-library/user-event'
import { vi } from 'vitest'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useAppContext } from '@/context/app-context'
import { useRouter } from '@/next/navigation'
import { AppModeEnum } from '@/types/app'
import NavSelector from '../index'
import { NavSelector } from '../index'
const mockPush = vi.fn()
// Mock next/navigation
vi.mock('@/next/navigation', () => ({
useRouter: vi.fn(),
useRouter: () => ({
push: mockPush,
}),
}))
// Mock app store
@ -28,7 +30,6 @@ describe('NavSelector Component', () => {
const mockSetAppDetail = vi.fn()
const mockOnCreate = vi.fn()
const mockOnLoadMore = vi.fn()
const mockPush = vi.fn()
const navigationItems: NavItem[] = [
{
@ -70,9 +71,6 @@ describe('NavSelector Component', () => {
vi.mocked(useAppContext).mockReturnValue({
isCurrentWorkspaceEditor: true,
} as unknown as AppContextValue)
vi.mocked(useRouter).mockReturnValue({
push: mockPush,
} as unknown as ReturnType<typeof useRouter>)
})
describe('Rendering', () => {
@ -101,7 +99,7 @@ describe('NavSelector Component', () => {
expect(screen.getByText('Item 2'))!.toBeInTheDocument()
})
it('should navigate and call setAppDetail when an item is clicked', async () => {
it('should call setAppDetail and navigate when an item is clicked', async () => {
render(<NavSelector {...defaultProps} />)
const button = screen.getByRole('button')
await act(async () => {
@ -115,7 +113,7 @@ describe('NavSelector Component', () => {
expect(mockPush).toHaveBeenCalledWith('/item2')
})
it('should not navigate if current item is clicked', async () => {
it('should render current item without a route link', async () => {
render(<NavSelector {...defaultProps} />)
const button = screen.getByRole('button')
await act(async () => {
@ -128,7 +126,8 @@ describe('NavSelector Component', () => {
fireEvent.click(listItem)
})
}
expect(mockPush).not.toHaveBeenCalled()
expect(listItem?.closest('a')).toBeNull()
expect(mockSetAppDetail).not.toHaveBeenCalled()
})
it('should call onCreate when create button is clicked (non-app mode)', async () => {

View File

@ -53,14 +53,14 @@ type AppCreateMenuProps = {
onCreate: (state: string) => void
}
const AppCreateMenu = ({
function AppCreateMenu({
createText,
startFromBlankText,
startFromTemplateText,
importDSLText,
onCreate,
}: AppCreateMenuProps) => {
const handleCreate = (state: string) => {
}: AppCreateMenuProps) {
function handleCreate(state: string) {
onCreate(state)
}
@ -111,9 +111,65 @@ const AppCreateMenu = ({
)
}
const NavSelector = ({ curNav, navigationItems, createText, isApp, onCreate, onLoadMore, isLoadingMore }: INavSelectorProps) => {
const { t } = useTranslation()
function NavSelectorItemContent({ nav }: {
nav: NavItem
}) {
return (
<>
<div className="relative mr-2 size-6 shrink-0 rounded-md">
<AppIcon
size="tiny"
iconType={nav.icon_type}
icon={nav.icon}
background={nav.icon_background}
imageUrl={nav.icon_url}
/>
{!!nav.mode && (
<AppTypeIcon type={nav.mode} wrapperClassName="absolute -bottom-0.5 -right-0.5 h-3.5 w-3.5 shadow-sm" className="size-2.5" />
)}
</div>
<div className="min-w-0 truncate">
{nav.name}
</div>
</>
)
}
function NavSelectorRouteItem({ nav, isCurrent, onBeforeNavigate }: {
nav: NavItem
isCurrent: boolean
onBeforeNavigate: () => void
}) {
const router = useRouter()
const className = 'h-auto truncate px-3 py-[6px] text-[14px] font-normal text-text-secondary'
function handleNavigate() {
onBeforeNavigate()
router.push(nav.link)
}
if (isCurrent) {
return (
<DropdownMenuItem
className={className}
>
<NavSelectorItemContent nav={nav} />
</DropdownMenuItem>
)
}
return (
<DropdownMenuItem
className={className}
onClick={handleNavigate}
>
<NavSelectorItemContent nav={nav} />
</DropdownMenuItem>
)
}
export function NavSelector({ curNav, navigationItems, createText, isApp, onCreate, onLoadMore, isLoadingMore }: INavSelectorProps) {
const { t } = useTranslation()
const { isCurrentWorkspaceEditor } = useAppContext()
const setAppDetail = useAppStore(state => state.setAppDetail)
@ -130,11 +186,11 @@ const NavSelector = ({ curNav, navigationItems, createText, isApp, onCreate, onL
<DropdownMenu modal={false}>
<DropdownMenuTrigger
className={cn(
'hover:hover:bg-components-main-nav-nav-button-bg-active-hover group inline-flex h-7 items-center justify-center rounded-[10px] pr-2.5 pl-2 text-[14px] font-semibold text-components-main-nav-nav-button-text-active outline-hidden',
'hover:hover:bg-components-main-nav-nav-button-bg-active-hover group inline-flex h-7 min-w-0 items-center justify-center rounded-[10px] pr-2.5 pl-2 text-[14px] font-semibold text-components-main-nav-nav-button-text-active outline-hidden',
'focus-visible:bg-components-main-nav-nav-button-bg-active focus-visible:ring-1 focus-visible:ring-components-input-border-hover data-popup-open:bg-components-main-nav-nav-button-bg-active',
)}
>
<div className="max-w-[157px] truncate" title={curNav?.name}>{curNav?.name}</div>
<div className="max-w-[157px] min-w-0 truncate">{curNav?.name}</div>
<RiArrowDownSLine
className="ml-1 size-3 shrink-0 opacity-50 group-hover:opacity-100 group-data-popup-open:opacity-100"
aria-hidden="true"
@ -148,33 +204,12 @@ const NavSelector = ({ curNav, navigationItems, createText, isApp, onCreate, onL
<div className="max-h-[50vh] overflow-auto px-1 py-1" onScroll={handleScroll}>
{
navigationItems.map(nav => (
<DropdownMenuItem
<NavSelectorRouteItem
key={nav.id}
className="h-auto truncate px-3 py-[6px] text-[14px] font-normal text-text-secondary"
onClick={() => {
if (curNav?.id === nav.id)
return
setAppDetail()
router.push(nav.link)
}}
title={nav.name}
>
<div className="relative mr-2 size-6 shrink-0 rounded-md">
<AppIcon
size="tiny"
iconType={nav.icon_type}
icon={nav.icon}
background={nav.icon_background}
imageUrl={nav.icon_url}
/>
{!!nav.mode && (
<AppTypeIcon type={nav.mode} wrapperClassName="absolute -bottom-0.5 -right-0.5 h-3.5 w-3.5 shadow-sm" className="size-2.5" />
)}
</div>
<div className="min-w-0 truncate">
{nav.name}
</div>
</DropdownMenuItem>
nav={nav}
isCurrent={curNav?.id === nav.id}
onBeforeNavigate={setAppDetail}
/>
))
}
{isLoadingMore && (
@ -209,5 +244,3 @@ const NavSelector = ({ curNav, navigationItems, createText, isApp, onCreate, onL
</DropdownMenu>
)
}
export default NavSelector

View File

@ -1,6 +1,6 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ToolsNav from '../index'
import { ToolsNav } from '../index'
const mockUseSelectedLayoutSegment = vi.fn()
vi.mock('@/next/navigation', () => ({

View File

@ -10,13 +10,11 @@ import { buildIntegrationPath } from '@/app/components/integrations/routes'
import Link from '@/next/link'
import { useSelectedLayoutSegment } from '@/next/navigation'
type ToolsNavProps = {
className?: string
}
const ToolsNav = ({
export function ToolsNav({
className,
}: ToolsNavProps) => {
}: {
className?: string
}) {
const { t } = useTranslation()
const selectedSegment = useSelectedLayoutSegment()
const activated = selectedSegment === 'integrations' || selectedSegment === 'tools'
@ -31,11 +29,9 @@ const ToolsNav = ({
? <RiHammerFill className="size-4" />
: <RiHammerLine className="size-4" />
}
<div className="ml-2 max-[1024px]:hidden">
<div className="ml-2 max-[1120px]:hidden">
{t('menus.tools', { ns: 'common' })}
</div>
</Link>
)
}
export default ToolsNav

View File

@ -159,6 +159,15 @@ vi.mock('@/app/components/app-sidebar/dataset-detail-top', () => ({
),
}))
vi.mock('@/features/deployments/detail/deployment-sidebar', () => ({
DeploymentDetailSection: ({ expand }: { expand: boolean }) => <div data-testid="deployment-detail-section" data-expand={expand} />,
DeploymentDetailTop: ({ expand, onToggle }: { expand: boolean, onToggle: () => void }) => (
<div data-testid="deployment-detail-top" data-expand={expand}>
<button type="button" data-testid="deployment-detail-toggle" onClick={onToggle}>Toggle</button>
</div>
),
}))
vi.mock('@/context/i18n', () => ({
useLocale: () => 'en-US',
useDocLink: () => (path: string) => `https://docs.dify.ai${path}`,
@ -240,8 +249,10 @@ const appContextValue: AppContextValue = {
isValidatingCurrentWorkspace: false,
}
type MainNavSystemFeatures = NonNullable<Parameters<typeof renderWithSystemFeatures>[1]>['systemFeatures']
const renderMainNav = (
systemFeatures = { branding: { enabled: false } },
systemFeatures: MainNavSystemFeatures = { branding: { enabled: false } },
options: { store?: ReturnType<typeof createStore>, extra?: ReactNode } = {},
) => {
const queryClient = createTestQueryClient()
@ -325,6 +336,22 @@ describe('MainNav', () => {
expect(screen.getByRole('link', { name: /common.mainNav.marketplace/ })).toHaveAttribute('href', '/marketplace')
})
it('renders deployments in primary navigation when app deploy is enabled', () => {
renderMainNav({ branding: { enabled: false }, enable_app_deploy: true })
const marketplaceLink = screen.getByRole('link', { name: /common.mainNav.marketplace/ })
const deploymentsLink = screen.getByRole('link', { name: /common.menus.deployments/ })
expect(deploymentsLink).toHaveAttribute('href', '/deployments')
expect(marketplaceLink.compareDocumentPosition(deploymentsLink)).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
})
it('hides deployments in primary navigation when app deploy is disabled', () => {
renderMainNav({ branding: { enabled: false }, enable_app_deploy: false })
expect(screen.queryByRole('link', { name: /common.menus.deployments/ })).not.toBeInTheDocument()
})
it('aligns the global navigation spacing with the main sidebar design', () => {
mockInstalledApps = [createInstalledApp()]
@ -452,12 +479,13 @@ describe('MainNav', () => {
isCurrentWorkspaceOwner: false,
})
renderMainNav()
renderMainNav({ branding: { enabled: false }, enable_app_deploy: true })
expect(screen.getByRole('link', { name: /common.mainNav.home/ })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /common.menus.apps/ })).toBeInTheDocument()
expect(screen.queryByRole('link', { name: /common.menus.datasets/ })).not.toBeInTheDocument()
expect(screen.getByRole('link', { name: /common.mainNav.integrations/ })).toBeInTheDocument()
expect(screen.queryByRole('link', { name: /common.menus.deployments/ })).not.toBeInTheDocument()
expect(screen.getByRole('link', { name: /common.mainNav.marketplace/ })).toBeInTheDocument()
})
@ -624,6 +652,50 @@ describe('MainNav', () => {
expect(screen.getByTestId('dataset-detail-section')).toHaveAttribute('data-expand', 'true')
})
it('replaces global navigation with deployment detail navigation on deployment routes', () => {
mockPathname = '/deployments/app-instance-1/releases'
renderMainNav({ branding: { enabled: false }, enable_app_deploy: true })
expect(screen.getByTestId('deployment-detail-top')).toBeInTheDocument()
expect(screen.getByTestId('deployment-detail-section')).toBeInTheDocument()
expect(screen.getByTestId('deployment-detail-top')).toHaveAttribute('data-expand', 'true')
expect(screen.getByTestId('deployment-detail-section')).toHaveAttribute('data-expand', 'true')
expect(screen.getByRole('complementary')).toHaveClass('w-[248px]')
expect(screen.getByRole('complementary')).toHaveClass('p-1')
expect(screen.getByRole('complementary')).toHaveClass('bg-background-body')
expect(screen.queryByRole('button', { name: 'common.mainNav.workspace.openMenu' })).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: /common.mainNav.home/ })).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: /common.menus.deployments/ })).not.toBeInTheDocument()
})
it('collapses deployment detail navigation from the top-right toggle', () => {
mockPathname = '/deployments/app-instance-1/releases'
renderMainNav({ branding: { enabled: false }, enable_app_deploy: true })
fireEvent.click(screen.getByTestId('deployment-detail-toggle'))
expect(screen.getByRole('complementary')).toHaveClass('w-16')
expect(screen.getByRole('complementary')).toHaveClass('p-1')
expect(screen.getByTestId('deployment-detail-top')).toHaveAttribute('data-expand', 'false')
expect(screen.getByTestId('deployment-detail-section')).toHaveAttribute('data-expand', 'false')
expect(localStorage.getItem('app-detail-collapse-or-expand')).toBe('collapse')
})
it.each([
'/deployments',
'/deployments/create',
])('keeps global navigation on deployment collection route %s', (pathname) => {
mockPathname = pathname
renderMainNav({ branding: { enabled: false }, enable_app_deploy: true })
expect(screen.queryByTestId('deployment-detail-top')).not.toBeInTheDocument()
expect(screen.queryByTestId('deployment-detail-section')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /common.menus.deployments/ })).toHaveAttribute('href', '/deployments')
})
it('registers the detail navigation shortcut to run while inputs are focused', () => {
mockPathname = '/app/app-1/overview'

View File

@ -17,6 +17,7 @@ import DifyLogo from '@/app/components/base/logo/dify-logo'
import EnvNav from '@/app/components/header/env-nav'
import { buildIntegrationPath } from '@/app/components/integrations/routes'
import { useAppContext } from '@/context/app-context'
import { DeploymentDetailSection, DeploymentDetailTop } from '@/features/deployments/detail/deployment-sidebar'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import Link from '@/next/link'
import { usePathname } from '@/next/navigation'
@ -29,6 +30,7 @@ import { WorkspaceCard } from './components/workspace-card'
const DATASET_COLLECTION_ROUTES = new Set(['create', 'create-from-pipeline', 'connect'])
const DATASET_DOCUMENT_CREATION_ROUTES = new Set(['create', 'create-from-pipeline'])
const DEPLOYMENT_COLLECTION_ROUTES = new Set(['create'])
const DETAIL_SIDEBAR_STORAGE_KEY = 'app-detail-collapse-or-expand'
const secondarySidebarHelpTriggerIcon = <span aria-hidden className="i-ri-question-line size-4 shrink-0" />
@ -60,6 +62,12 @@ const isDatasetDetailPathname = (pathname: string) => {
return true
}
const isDeploymentDetailPathname = (pathname: string) => {
const [section, appInstanceId] = pathname.split('/').filter(Boolean)
return section === 'deployments' && !!appInstanceId && !DEPLOYMENT_COLLECTION_ROUTES.has(appInstanceId)
}
const isSnippetDetailPathname = (pathname: string) => {
const [section, snippetId] = pathname.split('/').filter(Boolean)
@ -74,10 +82,12 @@ const MainNav = ({
const { langGeniusVersionInfo, isCurrentWorkspaceDatasetOperator, isCurrentWorkspaceEditor } = useAppContext()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const showEnvTag = langGeniusVersionInfo.current_env === 'TESTING' || langGeniusVersionInfo.current_env === 'DEVELOPMENT'
const canUseAppDeploy = isCurrentWorkspaceEditor && systemFeatures.enable_app_deploy
const showAppDetailNavigation = !isCurrentWorkspaceDatasetOperator && pathname.startsWith('/app/')
const showDatasetDetailNavigation = isDatasetDetailPathname(pathname)
const showDeploymentDetailNavigation = canUseAppDeploy && !isCurrentWorkspaceDatasetOperator && isDeploymentDetailPathname(pathname)
const showSnippetDetailBottomNavigation = isSnippetDetailPathname(pathname)
const showDetailNavigation = showAppDetailNavigation || showDatasetDetailNavigation
const showDetailNavigation = showAppDetailNavigation || showDatasetDetailNavigation || showDeploymentDetailNavigation
const { hasAppDetail, appSidebarExpand, setAppDetail, setAppSidebarExpand } = useAppStore(useShallow(state => ({
hasAppDetail: !!state.appDetail,
appSidebarExpand: state.appSidebarExpand,
@ -213,7 +223,16 @@ const MainNav = ({
icon: 'i-custom-vender-main-nav-marketplace',
activeIcon: 'i-custom-vender-main-nav-marketplace-active',
},
], [isCurrentWorkspaceDatasetOperator, isCurrentWorkspaceEditor, t])
...(canUseAppDeploy
? [{
href: '/deployments',
label: t('menus.deployments', { ns: 'common' }),
active: (path: string) => path.startsWith('/deployments'),
icon: 'i-ri-rocket-line',
activeIcon: 'i-ri-rocket-fill',
}]
: []),
], [canUseAppDeploy, isCurrentWorkspaceDatasetOperator, isCurrentWorkspaceEditor, t])
const renderLogo = () => (
<Link
@ -272,12 +291,19 @@ const MainNav = ({
onToggle={handleToggleDetailNavigation}
/>
)
: (
<DatasetDetailTop
expand={detailNavigationVisibleExpanded}
onToggle={handleToggleDetailNavigation}
/>
)
: showDatasetDetailNavigation
? (
<DatasetDetailTop
expand={detailNavigationVisibleExpanded}
onToggle={handleToggleDetailNavigation}
/>
)
: (
<DeploymentDetailTop
expand={detailNavigationVisibleExpanded}
onToggle={handleToggleDetailNavigation}
/>
)
: showSnippetDetailBottomNavigation
? null
: (
@ -294,7 +320,9 @@ const MainNav = ({
{showDetailNavigation
? showAppDetailNavigation
? <AppDetailSection expand={detailNavigationVisibleExpanded} />
: <DatasetDetailSection expand={detailNavigationVisibleExpanded} />
: showDatasetDetailNavigation
? <DatasetDetailSection expand={detailNavigationVisibleExpanded} />
: <DeploymentDetailSection expand={detailNavigationVisibleExpanded} />
: showSnippetDetailBottomNavigation
? null
: (

View File

@ -101,7 +101,7 @@ vi.mock('reactflow', () => ({
}))
vi.mock('@/app/components/app/app-publisher', () => ({
default: (props: AppPublisherProps) => {
AppPublisher: (props: AppPublisherProps) => {
const inputs = props.inputs ?? []
return (
<div

View File

@ -16,7 +16,7 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import { useEdges } from 'reactflow'
import AppPublisher from '@/app/components/app/app-publisher'
import { AppPublisher } from '@/app/components/app/app-publisher'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useFeatures } from '@/app/components/base/features/hooks'
import { Plan } from '@/app/components/billing/type'

View File

@ -4,7 +4,7 @@
* @param fallbackText - Text to show when finishedAt is not available (default: 'Running')
* @returns Formatted string like " (14:30:25)" or " (Running)"
*/
export const formatWorkflowRunIdentifier = (finishedAt?: number, fallbackText = 'Running'): string => {
export function formatWorkflowRunIdentifier(finishedAt?: number, fallbackText = 'Running'): string {
if (!finishedAt) {
const capitalized = fallbackText.charAt(0).toUpperCase() + fallbackText.slice(1)
return ` (${capitalized})`

View File

@ -1,34 +0,0 @@
import type { AccessControlAccount, AccessControlGroup } from '@/models/access-control'
import type { App } from '@/types/app'
import { create } from 'zustand'
import { AccessMode } from '@/models/access-control'
type AccessControlStore = {
appId: App['id']
setAppId: (appId: App['id']) => void
specificGroups: AccessControlGroup[]
setSpecificGroups: (specificGroups: AccessControlGroup[]) => void
specificMembers: AccessControlAccount[]
setSpecificMembers: (specificMembers: AccessControlAccount[]) => void
currentMenu: AccessMode
setCurrentMenu: (currentMenu: AccessMode) => void
selectedGroupsForBreadcrumb: AccessControlGroup[]
setSelectedGroupsForBreadcrumb: (selectedGroupsForBreadcrumb: AccessControlGroup[]) => void
}
const useAccessControlStore = create<AccessControlStore>((set) => {
return {
appId: '',
setAppId: appId => set({ appId }),
specificGroups: [],
setSpecificGroups: specificGroups => set({ specificGroups }),
specificMembers: [],
setSpecificMembers: specificMembers => set({ specificMembers }),
currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS,
setCurrentMenu: currentMenu => set({ currentMenu }),
selectedGroupsForBreadcrumb: [],
setSelectedGroupsForBreadcrumb: selectedGroupsForBreadcrumb => set({ selectedGroupsForBreadcrumb }),
}
})
export default useAccessControlStore

View File

@ -2,6 +2,8 @@
import type { QueryClient } from '@tanstack/react-query'
import { QueryClientProvider } from '@tanstack/react-query'
import { queryClientAtom } from 'jotai-tanstack-query'
import { useHydrateAtoms } from 'jotai/react/utils'
import { isServer } from '@/utils/client'
import { makeQueryClient } from './query-client-server'
@ -20,7 +22,21 @@ export const TanstackQueryInitializer = ({ children }: { children: React.ReactNo
const queryClient = getQueryClient()
return (
<QueryClientProvider client={queryClient}>
{children}
<HydrateJotaiQueryClient queryClient={queryClient}>
{children}
</HydrateJotaiQueryClient>
</QueryClientProvider>
)
}
function HydrateJotaiQueryClient({
children,
queryClient,
}: {
children: React.ReactNode
queryClient: QueryClient
}) {
useHydrateAtoms(new Map([[queryClientAtom, queryClient]]))
return children
}

View File

@ -1,3 +1,4 @@
import type { Subject } from '@dify/contracts/enterprise/types.gen'
import type { ChatConfig } from '@/app/components/base/chat/types'
import type { AccessMode } from '@/models/access-control'
import type { Banner } from '@/models/app'
@ -35,6 +36,12 @@ type AppAccessModeResponse = {
accessMode: AccessMode
}
type UpdateAppAccessModeBody = {
appId: string
accessMode: AccessMode
subjects?: Pick<Subject, 'subjectId' | 'subjectType'>[]
}
export const exploreAppsContract = base
.route({
path: '/explore/apps',
@ -96,6 +103,14 @@ export const exploreInstalledAppAccessModeContract = base
.input(type<{ query: { appId: string } }>())
.output(type<AppAccessModeResponse>())
export const exploreInstalledAppAccessModeUpdateContract = base
.route({
path: '/enterprise/webapp/app/access-mode',
method: 'POST',
})
.input(type<{ body: UpdateAppAccessModeBody }>())
.output(type<unknown>())
export const exploreInstalledAppParametersContract = base
.route({
path: '/installed-apps/{appId}/parameters',

View File

@ -15,6 +15,7 @@ import {
exploreAppsContract,
exploreBannersContract,
exploreInstalledAppAccessModeContract,
exploreInstalledAppAccessModeUpdateContract,
exploreInstalledAppMetaContract,
exploreInstalledAppParametersContract,
exploreInstalledAppPinContract,
@ -124,6 +125,7 @@ export const consoleRouterContract = {
uninstallInstalledApp: exploreInstalledAppUninstallContract,
updateInstalledApp: exploreInstalledAppPinContract,
appAccessMode: exploreInstalledAppAccessModeContract,
updateAppAccessMode: exploreInstalledAppAccessModeUpdateContract,
installedAppParameters: exploreInstalledAppParametersContract,
installedAppMeta: exploreInstalledAppMetaContract,
banners: exploreBannersContract,

View File

@ -0,0 +1,53 @@
import type { EnvVarSlot } from '@dify/contracts/enterprise/types.gen'
import { EnvVarValueType } from '@dify/contracts/enterprise/types.gen'
import { describe, expect, it } from 'vitest'
import {
envVarBindingSlotFromContract,
envVarBindingValueType,
} from '../env-var-bindings-utils'
function slot(overrides: Partial<EnvVarSlot>): EnvVarSlot {
return {
key: 'API_KEY',
valueType: EnvVarValueType.ENV_VAR_VALUE_TYPE_STRING,
description: '',
...overrides,
}
}
describe('env var binding value type normalization', () => {
it('should normalize generated and DSL value types', () => {
expect(envVarBindingValueType(EnvVarValueType.ENV_VAR_VALUE_TYPE_NUMBER)).toBe('number')
expect(envVarBindingValueType('number')).toBe('number')
expect(envVarBindingValueType(EnvVarValueType.ENV_VAR_VALUE_TYPE_SECRET)).toBe('secret')
expect(envVarBindingValueType('secret')).toBe('secret')
expect(envVarBindingValueType(EnvVarValueType.ENV_VAR_VALUE_TYPE_STRING)).toBe('string')
expect(envVarBindingValueType()).toBe('string')
})
})
describe('env var contract slot conversion', () => {
it('should trim keys and preserve default and last value availability', () => {
expect(envVarBindingSlotFromContract(slot({
key: ' API_TOKEN ',
valueType: EnvVarValueType.ENV_VAR_VALUE_TYPE_SECRET,
defaultValue: '',
lastValue: 'previous',
}))).toMatchObject({
key: 'API_TOKEN',
valueType: 'secret',
hasDefaultValue: true,
hasLastValue: true,
})
})
it('should ignore blank keys and mark absent values as unavailable', () => {
expect(envVarBindingSlotFromContract(slot({ key: ' ' }))).toBeUndefined()
expect(envVarBindingSlotFromContract(slot({ key: 'PORT', valueType: EnvVarValueType.ENV_VAR_VALUE_TYPE_NUMBER }))).toMatchObject({
key: 'PORT',
valueType: 'number',
hasDefaultValue: false,
hasLastValue: false,
})
})
})

View File

@ -0,0 +1,129 @@
import type {
CredentialCandidate,
CredentialSlot,
} from '@dify/contracts/enterprise/types.gen'
import { PluginCategory } from '@dify/contracts/enterprise/types.gen'
import { describe, expect, it } from 'vitest'
import {
hasMissingRequiredRuntimeCredentialBinding,
runtimeCredentialCandidateOptions,
runtimeCredentialProviderName,
runtimeCredentialSlotKey,
selectedDeploymentRuntimeCredentials,
selectedRuntimeCredentialSelections,
} from '../runtime-credential-bindings-utils'
function candidate(overrides: Partial<CredentialCandidate>): CredentialCandidate {
return {
credentialId: 'credential-1',
providerId: 'langgenius/openai',
category: PluginCategory.PLUGIN_CATEGORY_MODEL,
displayName: 'OpenAI key',
fromEnterprise: false,
...overrides,
}
}
function slot(overrides: Partial<CredentialSlot>): CredentialSlot {
return {
providerId: 'langgenius/openai',
category: PluginCategory.PLUGIN_CATEGORY_MODEL,
candidates: [
candidate({ credentialId: 'credential-1', displayName: 'Primary key' }),
candidate({ credentialId: 'credential-2', displayName: 'Backup key' }),
],
lastCredentialId: '',
...overrides,
}
}
describe('runtime credential provider names', () => {
it('should resolve known provider slugs and title-case custom slugs', () => {
expect(runtimeCredentialProviderName('langgenius/openai')).toBe('OpenAI')
expect(runtimeCredentialProviderName('langgenius/azure_openai')).toBe('Azure OpenAI')
expect(runtimeCredentialProviderName('custom/my-provider')).toBe('My Provider')
expect(runtimeCredentialProviderName('/')).toBeUndefined()
})
})
describe('runtime credential selection helpers', () => {
it('should build stable slot keys and candidate labels', () => {
const credentialSlot = slot({
candidates: [
candidate({
credentialId: 'credential-1',
providerId: 'langgenius/openai',
displayName: 'Production key · langgenius/openai',
}),
candidate({
credentialId: 'credential-2',
providerId: 'langgenius/openai',
displayName: 'Backup key (langgenius/openai)',
}),
candidate({
credentialId: 'credential-3',
providerId: 'langgenius/openai',
displayName: '',
}),
],
})
expect(runtimeCredentialSlotKey(credentialSlot)).toBe('langgenius/openai:PLUGIN_CATEGORY_MODEL')
expect(runtimeCredentialCandidateOptions(credentialSlot)).toEqual([
{ value: 'credential-1', label: 'Production key' },
{ value: 'credential-2', label: 'Backup key' },
{ value: 'credential-3', label: 'credential-3' },
])
})
it('should prefer valid manual selections before last or only candidates', () => {
const firstSlot = slot({
providerId: 'langgenius/openai',
lastCredentialId: 'credential-2',
})
const secondSlot = slot({
providerId: 'langgenius/bedrock',
candidates: [candidate({ credentialId: 'bedrock-1', providerId: 'langgenius/bedrock' })],
lastCredentialId: '',
})
const thirdSlot = slot({
providerId: 'custom/empty',
candidates: [
candidate({ credentialId: 'empty-1', providerId: 'custom/empty' }),
candidate({ credentialId: 'empty-2', providerId: 'custom/empty' }),
],
lastCredentialId: 'stale',
})
expect(selectedRuntimeCredentialSelections(
[firstSlot, secondSlot, thirdSlot],
{
[runtimeCredentialSlotKey(firstSlot)]: 'credential-1',
[runtimeCredentialSlotKey(secondSlot)]: 'stale',
},
)).toEqual({
[runtimeCredentialSlotKey(firstSlot)]: 'credential-1',
[runtimeCredentialSlotKey(secondSlot)]: 'bedrock-1',
})
})
it('should convert selected credentials into deployment payload inputs', () => {
const firstSlot = slot({ providerId: 'langgenius/openai' })
const secondSlot = slot({ providerId: 'langgenius/bedrock' })
expect(hasMissingRequiredRuntimeCredentialBinding(firstSlot)).toBe(true)
expect(hasMissingRequiredRuntimeCredentialBinding(firstSlot, 'credential-1')).toBe(false)
expect(selectedDeploymentRuntimeCredentials(
[firstSlot, secondSlot],
{
[runtimeCredentialSlotKey(firstSlot)]: 'credential-1',
},
)).toEqual([
{
providerId: 'langgenius/openai',
category: PluginCategory.PLUGIN_CATEGORY_MODEL,
credentialId: 'credential-1',
},
])
})
})

View File

@ -0,0 +1,88 @@
'use client'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { useRouter } from '@/next/navigation'
import { consoleQuery } from '@/service/client'
export function DeleteDeploymentDialog({
appInstanceId,
appName,
open,
onOpenChange,
}: {
appInstanceId: string
appName?: string
open: boolean
onOpenChange: (open: boolean) => void
}) {
const { t } = useTranslation('deployments')
const router = useRouter()
const deleteInstance = useMutation(consoleQuery.enterprise.appInstanceService.deleteAppInstance.mutationOptions())
const displayName = appName || appInstanceId
function handleDelete() {
deleteInstance.mutate(
{
params: {
appInstanceId,
},
},
{
onSuccess: () => {
toast.success(t('settings.deleted'))
router.push('/deployments')
},
onError: () => {
toast.error(t('settings.deleteFailed'))
},
onSettled: () => {
onOpenChange(false)
},
},
)
}
return (
<AlertDialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen && deleteInstance.isPending)
return
onOpenChange(nextOpen)
}}
>
<AlertDialogContent className="w-120">
<div className="flex flex-col gap-3 px-6 pt-6 pb-2">
<AlertDialogTitle className="title-2xl-semi-bold text-text-primary">
{t('settings.deleteConfirmTitle')}
</AlertDialogTitle>
<AlertDialogDescription className="system-sm-regular text-text-tertiary">
{t('settings.deleteConfirmDesc', { name: displayName })}
</AlertDialogDescription>
</div>
<AlertDialogActions className="pt-3">
<AlertDialogCancelButton variant="secondary" disabled={deleteInstance.isPending}>
{t('createModal.cancel')}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
loading={deleteInstance.isPending}
onClick={handleDelete}
>
{t('settings.delete')}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@ -0,0 +1,206 @@
'use client'
import type { AppInstance } from '@dify/contracts/enterprise/types.gen'
import type { FormEvent } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import {
Dialog,
DialogCloseButton,
DialogContent,
DialogTitle,
} from '@langgenius/dify-ui/dialog'
import { Input } from '@langgenius/dify-ui/input'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { consoleQuery } from '@/service/client'
type EditDeploymentFormValues = {
name: string
description: string
}
function EditDeploymentFormSkeleton() {
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<SkeletonRectangle className="my-0 h-3 w-24 animate-pulse" />
<SkeletonRectangle className="my-0 h-8 w-full animate-pulse rounded-lg" />
</div>
<div className="flex flex-col gap-2">
<SkeletonRectangle className="my-0 h-3 w-28 animate-pulse" />
<SkeletonRectangle className="my-0 h-24 w-full animate-pulse rounded-lg" />
</div>
<SkeletonRow className="justify-end gap-2">
<SkeletonRectangle className="my-0 h-8 w-16 animate-pulse rounded-lg" />
<SkeletonRectangle className="my-0 h-8 w-24 animate-pulse rounded-lg" />
</SkeletonRow>
</div>
)
}
function EditDeploymentForm({
app,
isSaving,
onClose,
onSubmit,
}: {
app: AppInstance
isSaving: boolean
onClose: () => void
onSubmit: (values: EditDeploymentFormValues) => void
}) {
const { t } = useTranslation('deployments')
const initialName = app.displayName
const initialDescription = app.description
const [name, setName] = useState(initialName)
const [description, setDescription] = useState(initialDescription)
const normalizedName = name.trim()
const normalizedDescription = description.trim()
const canSave = Boolean(normalizedName && (normalizedName !== initialName || normalizedDescription !== initialDescription) && !isSaving)
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!canSave)
return
onSubmit({
name: normalizedName,
description: normalizedDescription,
})
}
return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="deployment-edit-name">
{t('settings.name')}
</label>
<Input
id="deployment-edit-name"
type="text"
value={name}
onChange={event => setName(event.target.value)}
className="h-8"
/>
</div>
<div className="flex flex-col gap-2">
<label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="deployment-edit-description">
{t('settings.description')}
</label>
<Textarea
id="deployment-edit-description"
value={description}
onValueChange={setDescription}
className="min-h-24"
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button
type="button"
variant="secondary"
disabled={isSaving}
onClick={onClose}
>
{t('createModal.cancel')}
</Button>
<Button
type="submit"
variant="primary"
disabled={!canSave}
loading={isSaving}
>
{t('settings.save')}
</Button>
</div>
</form>
)
}
export function EditDeploymentDialog({
appInstanceId,
open,
onOpenChange,
}: {
appInstanceId: string
open: boolean
onOpenChange: (open: boolean) => void
}) {
const { t } = useTranslation('deployments')
const updateInstance = useMutation(consoleQuery.enterprise.appInstanceService.updateAppInstance.mutationOptions())
const instanceQuery = useQuery(consoleQuery.enterprise.appInstanceService.getAppInstance.queryOptions({
input: {
params: { appInstanceId },
},
enabled: open,
}))
const app = instanceQuery.data?.appInstance
const formKey = app ? `${app.id}-${app.displayName}-${app.description}` : 'loading'
function handleOpenChange(nextOpen: boolean) {
if (!nextOpen && updateInstance.isPending)
return
onOpenChange(nextOpen)
}
function handleClose() {
handleOpenChange(false)
}
function handleSubmit(values: EditDeploymentFormValues) {
updateInstance.mutate(
{
params: {
appInstanceId,
},
body: {
appInstanceId,
displayName: values.name,
description: values.description || undefined,
},
},
{
onSuccess: () => {
toast.success(t('settings.updated'))
onOpenChange(false)
},
onError: () => {
toast.error(t('settings.updateFailed'))
},
},
)
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="w-120 max-w-[calc(100vw-32px)] p-0">
<DialogCloseButton disabled={updateInstance.isPending} />
<div className="border-b border-divider-subtle px-6 py-5">
<DialogTitle className="title-xl-semi-bold text-text-primary">
{t('card.menu.editInfo')}
</DialogTitle>
</div>
<div className="px-6 py-5">
{instanceQuery.isLoading
? <EditDeploymentFormSkeleton />
: instanceQuery.isError
? <div className="system-sm-regular text-text-tertiary">{t('common.loadFailed')}</div>
: app
? (
<EditDeploymentForm
key={formKey}
app={app}
isSaving={updateInstance.isPending}
onClose={handleClose}
onSubmit={handleSubmit}
/>
)
: <div className="system-sm-regular text-text-tertiary">{t('detail.notFound')}</div>}
</div>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,34 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { DeploymentActionsMenu } from './index'
vi.mock('@langgenius/dify-ui/dropdown-menu', () => import('@/__mocks__/base-ui-dropdown-menu'))
vi.mock('./edit-dialog', () => ({
EditDeploymentDialog: () => null,
}))
vi.mock('./delete-dialog', () => ({
DeleteDeploymentDialog: () => null,
}))
describe('DeploymentActionsMenu', () => {
it('keeps the trigger wrapper visible while the menu is open', () => {
const { container } = render(
<DeploymentActionsMenu
appInstanceId="app-instance-1"
placement="bottom-end"
className="pointer-events-none opacity-0"
/>,
)
const wrapper = container.querySelector('[role="presentation"]') as HTMLElement
expect(wrapper).toHaveClass('pointer-events-none', 'opacity-0')
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
expect(screen.getByText('deployments.card.menu.editInfo')).toBeInTheDocument()
expect(wrapper).toHaveClass('pointer-events-auto', 'opacity-100')
expect(wrapper).not.toHaveClass('pointer-events-none', 'opacity-0')
})
})

View File

@ -0,0 +1,101 @@
'use client'
import type { ComponentProps } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { DeleteDeploymentDialog } from './delete-dialog'
import { EditDeploymentDialog } from './edit-dialog'
const ACTION_TRIGGER_CLASS_NAME = cn(
'inline-flex size-8 items-center justify-center rounded-lg bg-components-panel-bg text-text-tertiary shadow-xs outline-hidden',
'hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid',
'data-popup-open:bg-state-base-hover data-popup-open:text-text-secondary',
)
type DeploymentActionsMenuProps = {
appInstanceId: string
appName?: string
className?: string
triggerClassName?: string
placement: ComponentProps<typeof DropdownMenuContent>['placement']
sideOffset?: ComponentProps<typeof DropdownMenuContent>['sideOffset']
}
export function DeploymentActionsMenu({
appInstanceId,
appName,
className,
triggerClassName,
placement,
sideOffset,
}: DeploymentActionsMenuProps) {
const { t } = useTranslation('deployments')
const [menuOpen, setMenuOpen] = useState(false)
const [editOpen, setEditOpen] = useState(false)
const [deleteOpen, setDeleteOpen] = useState(false)
function openEditDialog() {
setMenuOpen(false)
setEditOpen(true)
}
function openDeleteDialog() {
setMenuOpen(false)
setDeleteOpen(true)
}
return (
<div
role="presentation"
className={cn(className, menuOpen && 'pointer-events-auto opacity-100')}
onClick={event => event.stopPropagation()}
onKeyDown={event => event.stopPropagation()}
>
<DropdownMenu modal={false} open={menuOpen} onOpenChange={setMenuOpen}>
<DropdownMenuTrigger
aria-label={t('card.moreActions')}
className={cn(ACTION_TRIGGER_CLASS_NAME, triggerClassName)}
>
<span aria-hidden className="i-ri-more-fill size-4" />
</DropdownMenuTrigger>
{menuOpen && (
<DropdownMenuContent placement={placement} sideOffset={sideOffset} popupClassName="min-w-44">
<DropdownMenuItem className="gap-2 px-3" onClick={openEditDialog}>
<span aria-hidden className="i-ri-edit-line size-4 shrink-0 text-text-tertiary" />
<span className="system-sm-regular text-text-secondary">{t('card.menu.editInfo')}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
className="gap-2 px-3"
onClick={openDeleteDialog}
>
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
<span className="system-sm-regular">{t('card.menu.delete')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
)}
</DropdownMenu>
<EditDeploymentDialog
appInstanceId={appInstanceId}
open={editOpen}
onOpenChange={setEditOpen}
/>
<DeleteDeploymentDialog
appInstanceId={appInstanceId}
appName={appName}
open={deleteOpen}
onOpenChange={setDeleteOpen}
/>
</div>
)
}

View File

@ -0,0 +1,143 @@
'use client'
import type { ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
type DeploymentEmptyStateVariant = 'page' | 'list' | 'section' | 'compact'
type DeploymentStateMessageVariant = 'page' | 'list' | 'section' | 'compact' | 'embedded'
type DeploymentEmptyStateAlign = 'center' | 'start'
type DeploymentEmptyStateProps = {
icon?: string
title: ReactNode
description?: ReactNode
action?: ReactNode
variant?: DeploymentEmptyStateVariant
align?: DeploymentEmptyStateAlign
className?: string
}
type DeploymentStateMessageProps = {
children: ReactNode
variant?: DeploymentStateMessageVariant
className?: string
}
type DeploymentNoticeStateProps = {
children: ReactNode
icon?: string
className?: string
}
const emptyStateContainerClassNames: Record<DeploymentEmptyStateVariant, string> = {
page: 'col-span-full min-h-80 rounded-xl border border-divider-subtle bg-background-default-subtle px-6 py-12',
list: 'min-h-60 rounded-lg border border-divider-subtle bg-background-default-subtle px-6 py-12',
section: 'min-h-36 rounded-lg border border-divider-subtle bg-background-default-subtle px-6 py-8',
compact: 'min-h-14 rounded-lg border border-divider-subtle bg-background-default-subtle px-3 py-3',
}
const stateMessageClassNames: Record<DeploymentStateMessageVariant, string> = {
page: 'col-span-full flex min-h-80 items-center justify-center rounded-xl border border-dashed border-divider-subtle bg-background-default-subtle px-6 py-12 text-center system-sm-regular text-text-tertiary',
list: 'flex min-h-36 items-center justify-center rounded-lg border border-dashed border-divider-subtle bg-background-default-subtle px-6 py-12 text-center system-sm-regular text-text-tertiary',
section: 'flex min-h-24 items-center justify-center rounded-lg border border-dashed border-divider-subtle bg-background-default-subtle px-4 py-6 text-center system-sm-regular text-text-tertiary',
compact: 'rounded-lg border border-dashed border-divider-subtle bg-background-default-subtle px-3 py-3 system-sm-regular text-text-tertiary',
embedded: 'px-4 py-10 text-center system-sm-regular text-text-tertiary',
}
export function DeploymentEmptyState({
icon,
title,
description,
action,
variant = 'list',
align,
className,
}: DeploymentEmptyStateProps) {
const effectiveAlign = align ?? (variant === 'compact' ? 'start' : 'center')
const isLarge = variant === 'page' || variant === 'list'
const hasDescription = Boolean(description)
const hasAction = Boolean(action)
const hasIcon = Boolean(icon)
return (
<div
data-slot="deployment-empty-state"
className={cn(
'flex flex-col justify-center border-dashed',
effectiveAlign === 'center' ? 'items-center text-center' : 'items-start text-left',
emptyStateContainerClassNames[variant],
className,
)}
>
{hasIcon && (
<span
className={cn(
'flex items-center justify-center border border-components-panel-border bg-background-default-subtle text-text-tertiary',
variant === 'compact' ? 'mb-2 size-8 rounded-lg' : 'mb-4',
isLarge && 'size-11 rounded-xl',
variant === 'section' && 'size-10 rounded-lg bg-background-section-burn',
)}
>
<span
className={cn(
icon,
isLarge ? 'size-5' : variant === 'section' ? 'size-4.5' : 'size-4',
)}
aria-hidden="true"
/>
</span>
)}
<div
className={cn(
isLarge
? 'system-md-semibold text-text-primary'
: variant === 'compact' && !hasIcon && !hasDescription
? 'system-sm-regular text-text-tertiary'
: 'system-sm-medium text-text-secondary',
)}
>
{title}
</div>
{hasDescription && (
<p
className={cn(
'mt-1 max-w-120 text-text-tertiary',
isLarge ? 'system-sm-regular' : 'system-xs-regular',
)}
>
{description}
</p>
)}
{hasAction && (
<div className={isLarge ? 'mt-5' : variant === 'compact' ? 'mt-3' : 'mt-4'}>
{action}
</div>
)}
</div>
)
}
export function DeploymentStateMessage({
children,
variant = 'list',
className,
}: DeploymentStateMessageProps) {
return (
<div className={cn(stateMessageClassNames[variant], className)}>
{children}
</div>
)
}
export function DeploymentNoticeState({
children,
icon = 'i-ri-information-line',
className,
}: DeploymentNoticeStateProps) {
return (
<div className={cn('flex min-h-9 items-start gap-1.5 rounded-lg border border-divider-subtle bg-background-default-subtle px-3 py-2 system-xs-regular text-text-tertiary', className)}>
<span className={cn(icon, 'mt-0.5 size-3.5 shrink-0 text-text-quaternary')} aria-hidden="true" />
<span className="min-w-0">{children}</span>
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More