From b44faec66b5ce96716d371385d9ffed6afb502f8 Mon Sep 17 00:00:00 2001 From: CaIon Date: Wed, 29 Apr 2026 18:09:11 +0800 Subject: [PATCH] feat(ui): overhaul default channel editor with full param override visual editor - Port classic ParamOverrideEditorModal to default as standalone dialog (~3200 lines) with two-panel layout, drag-to-reorder, 23 operation modes, template library, visual/JSON dual mode, conditions management, and legacy format support - Redesign channel drawer layout with clear visual hierarchy (CardHeading vs SubHeading) and bordered sub-modules for Field Passthrough and Upstream Model Detection - Replace header override JsonEditor with plain textarea matching classic behavior - Add searchable channel type combobox with scroll fix - Add 100+ i18n keys across all 6 locales (en, zh, fr, ja, ru, vi) --- web/default/src/components/ui/combobox.tsx | 7 +- .../dialogs/param-override-editor-dialog.tsx | 3248 +++++++++++++++++ .../drawers/channel-mutate-drawer.tsx | 1976 +++++----- .../src/features/channels/constants.ts | 35 +- .../src/features/channels/lib/channel-form.ts | 29 + web/default/src/features/channels/types.ts | 9 + .../i18n/locales/_reports/_sync-report.json | 4 +- .../locales/_reports/ja.untranslated.json | 1 + .../locales/_reports/ru.untranslated.json | 1 + web/default/src/i18n/locales/en.json | 196 +- web/default/src/i18n/locales/fr.json | 202 +- web/default/src/i18n/locales/ja.json | 214 +- web/default/src/i18n/locales/ru.json | 210 +- web/default/src/i18n/locales/vi.json | 204 +- web/default/src/i18n/locales/zh.json | 214 +- 15 files changed, 5658 insertions(+), 892 deletions(-) create mode 100644 web/default/src/features/channels/components/dialogs/param-override-editor-dialog.tsx diff --git a/web/default/src/components/ui/combobox.tsx b/web/default/src/components/ui/combobox.tsx index 4e169430..8975ba93 100644 --- a/web/default/src/components/ui/combobox.tsx +++ b/web/default/src/components/ui/combobox.tsx @@ -103,7 +103,12 @@ export function Combobox({ - + e.stopPropagation()} + onTouchMove={(e) => e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + > void + onSave: (value: string) => void +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const OPERATION_MODE_OPTIONS = [ + { label: 'Set Field', value: 'set' }, + { label: 'Delete Field', value: 'delete' }, + { label: 'Append to End', value: 'append' }, + { label: 'Prepend to Start', value: 'prepend' }, + { label: 'Copy Field', value: 'copy' }, + { label: 'Move Field', value: 'move' }, + { label: 'String Replace', value: 'replace' }, + { label: 'Regex Replace', value: 'regex_replace' }, + { label: 'Trim Prefix', value: 'trim_prefix' }, + { label: 'Trim Suffix', value: 'trim_suffix' }, + { label: 'Ensure Prefix', value: 'ensure_prefix' }, + { label: 'Ensure Suffix', value: 'ensure_suffix' }, + { label: 'Trim Space', value: 'trim_space' }, + { label: 'To Lowercase', value: 'to_lower' }, + { label: 'To Uppercase', value: 'to_upper' }, + { label: 'Return Custom Error', value: 'return_error' }, + { label: 'Prune Object Items', value: 'prune_objects' }, + { label: 'Pass Through Headers', value: 'pass_headers' }, + { label: 'Sync Fields', value: 'sync_fields' }, + { label: 'Set Request Header', value: 'set_header' }, + { label: 'Delete Request Header', value: 'delete_header' }, + { label: 'Copy Request Header', value: 'copy_header' }, + { label: 'Move Request Header', value: 'move_header' }, +] + +const OPERATION_MODE_VALUES = new Set( + OPERATION_MODE_OPTIONS.map((o) => o.value) +) + +const OPERATION_MODE_LABEL_MAP = OPERATION_MODE_OPTIONS.reduce< + Record +>((acc, item) => { + acc[item.value] = item.label + return acc +}, {}) + +const CONDITION_MODE_OPTIONS = [ + { label: 'Exact Match', value: 'full' }, + { label: 'Prefix', value: 'prefix' }, + { label: 'Suffix', value: 'suffix' }, + { label: 'Contains', value: 'contains' }, + { label: 'Greater Than', value: 'gt' }, + { label: 'Greater Than or Equal', value: 'gte' }, + { label: 'Less Than', value: 'lt' }, + { label: 'Less Than or Equal', value: 'lte' }, +] + +const CONDITION_MODE_VALUES = new Set( + CONDITION_MODE_OPTIONS.map((o) => o.value) +) + +const MODE_META: Record< + string, + { + path?: boolean + pathOptional?: boolean + value?: boolean + from?: boolean + to?: boolean + keepOrigin?: boolean + pathAlias?: boolean + } +> = { + delete: { path: true }, + set: { path: true, value: true, keepOrigin: true }, + append: { path: true, value: true, keepOrigin: true }, + prepend: { path: true, value: true, keepOrigin: true }, + copy: { from: true, to: true }, + move: { from: true, to: true }, + replace: { path: true, from: true, to: false }, + regex_replace: { path: true, from: true, to: false }, + trim_prefix: { path: true, value: true }, + trim_suffix: { path: true, value: true }, + ensure_prefix: { path: true, value: true }, + ensure_suffix: { path: true, value: true }, + trim_space: { path: true }, + to_lower: { path: true }, + to_upper: { path: true }, + return_error: { value: true }, + prune_objects: { pathOptional: true, value: true }, + pass_headers: { value: true, keepOrigin: true }, + sync_fields: { from: true, to: true }, + set_header: { path: true, value: true, keepOrigin: true }, + delete_header: { path: true }, + copy_header: { from: true, to: true, keepOrigin: true, pathAlias: true }, + move_header: { from: true, to: true, keepOrigin: true, pathAlias: true }, +} + +const VALUE_REQUIRED_MODES = new Set([ + 'trim_prefix', + 'trim_suffix', + 'ensure_prefix', + 'ensure_suffix', + 'set_header', + 'return_error', + 'prune_objects', + 'pass_headers', +]) + +const FROM_REQUIRED_MODES = new Set([ + 'copy', + 'move', + 'replace', + 'regex_replace', + 'copy_header', + 'move_header', + 'sync_fields', +]) + +const TO_REQUIRED_MODES = new Set([ + 'copy', + 'move', + 'copy_header', + 'move_header', + 'sync_fields', +]) + +const MODE_DESCRIPTIONS: Record = { + set: 'Write value to the target field', + delete: 'Remove the target field', + append: 'Append value to array / string / object end', + prepend: 'Prepend value to array / string / object start', + copy: 'Copy source field to target field', + move: 'Move source field to target field', + replace: 'Do string replacement in the target field', + regex_replace: 'Do regex replacement in the target field', + trim_prefix: 'Remove string prefix', + trim_suffix: 'Remove string suffix', + ensure_prefix: 'Ensure the string has a specified prefix', + ensure_suffix: 'Ensure the string has a specified suffix', + trim_space: 'Trim leading/trailing whitespace', + to_lower: 'Convert string to lowercase', + to_upper: 'Convert string to uppercase', + return_error: 'Return a custom error immediately', + prune_objects: 'Prune object items by conditions', + pass_headers: 'Pass specified request headers to upstream', + sync_fields: 'Auto-fill when one field exists and another is missing', + set_header: + 'Set runtime request header: override entire value, or manipulate comma-separated tokens', + delete_header: 'Delete a runtime request header', + copy_header: 'Copy a request header', + move_header: 'Move a request header', +} + +const SYNC_TARGET_TYPE_OPTIONS = [ + { label: 'Request Body Field', value: 'json' }, + { label: 'Request Header Field', value: 'header' }, +] + +// Templates + +const LEGACY_TEMPLATE = { temperature: 0, max_tokens: 1000 } + +const OPERATION_TEMPLATE = { + operations: [ + { + description: 'Set default temperature for openai/* models.', + path: 'temperature', + mode: 'set', + value: 0.7, + conditions: [{ path: 'model', mode: 'prefix', value: 'openai/' }], + logic: 'AND', + }, + ], +} + +const HEADER_PASSTHROUGH_TEMPLATE = { + operations: [ + { + description: 'Pass through X-Request-Id header to upstream.', + mode: 'pass_headers', + value: ['X-Request-Id'], + keep_origin: true, + }, + ], +} + +const GEMINI_IMAGE_4K_TEMPLATE = { + operations: [ + { + description: + 'Set imageSize to 4K when model contains gemini/image and ends with 4k.', + mode: 'set', + path: 'generationConfig.imageConfig.imageSize', + value: '4K', + conditions: [ + { path: 'original_model', mode: 'contains', value: 'gemini' }, + { path: 'original_model', mode: 'contains', value: 'image' }, + { path: 'original_model', mode: 'suffix', value: '4k' }, + ], + logic: 'AND', + }, + ], +} + +const CODEX_CLI_HEADER_PASSTHROUGH_HEADERS = [ + 'Originator', + 'Session_id', + 'User-Agent', + 'X-Codex-Beta-Features', + 'X-Codex-Turn-Metadata', +] + +const CLAUDE_CLI_HEADER_PASSTHROUGH_HEADERS = [ + 'X-Stainless-Arch', + 'X-Stainless-Lang', + 'X-Stainless-Os', + 'X-Stainless-Package-Version', + 'X-Stainless-Retry-Count', + 'X-Stainless-Runtime', + 'X-Stainless-Runtime-Version', + 'X-Stainless-Timeout', + 'User-Agent', + 'X-App', + 'Anthropic-Beta', + 'Anthropic-Dangerous-Direct-Browser-Access', + 'Anthropic-Version', +] + +const buildPassHeadersTemplate = (headers: string[]) => ({ + operations: [ + { mode: 'pass_headers', value: [...headers], keep_origin: true }, + ], +}) + +const CODEX_CLI_HEADER_PASSTHROUGH_TEMPLATE = buildPassHeadersTemplate( + CODEX_CLI_HEADER_PASSTHROUGH_HEADERS +) +const CLAUDE_CLI_HEADER_PASSTHROUGH_TEMPLATE = buildPassHeadersTemplate( + CLAUDE_CLI_HEADER_PASSTHROUGH_HEADERS +) + +const AWS_BEDROCK_ANTHROPIC_COMPAT_TEMPLATE = { + operations: [ + { + description: + 'Normalize anthropic-beta header tokens for Bedrock compatibility.', + mode: 'set_header', + path: 'anthropic-beta', + value: { + 'advanced-tool-use-2025-11-20': 'tool-search-tool-2025-10-19', + bash_20241022: null, + bash_20250124: null, + 'code-execution-2025-08-25': null, + 'compact-2026-01-12': 'compact-2026-01-12', + 'computer-use-2025-01-24': 'computer-use-2025-01-24', + 'computer-use-2025-11-24': 'computer-use-2025-11-24', + 'context-1m-2025-08-07': 'context-1m-2025-08-07', + 'context-management-2025-06-27': 'context-management-2025-06-27', + 'effort-2025-11-24': null, + 'fast-mode-2026-02-01': null, + 'files-api-2025-04-14': null, + 'fine-grained-tool-streaming-2025-05-14': null, + 'interleaved-thinking-2025-05-14': 'interleaved-thinking-2025-05-14', + 'mcp-client-2025-11-20': null, + 'mcp-client-2025-04-04': null, + 'mcp-servers-2025-12-04': null, + 'output-128k-2025-02-19': null, + 'structured-output-2024-03-01': null, + 'prompt-caching-scope-2026-01-05': null, + 'skills-2025-10-02': null, + 'structured-outputs-2025-11-13': null, + text_editor_20241022: null, + text_editor_20250124: null, + 'token-efficient-tools-2025-02-19': null, + 'tool-search-tool-2025-10-19': 'tool-search-tool-2025-10-19', + 'web-fetch-2025-09-10': null, + 'web-search-2025-03-05': null, + 'oauth-2025-04-20': null, + }, + }, + { + description: + 'Remove all tools[*].custom.input_examples before upstream relay.', + mode: 'delete', + path: 'tools.*.custom.input_examples', + }, + ], +} + +type TemplatePresetConfig = { + label: string + kind: 'operations' | 'legacy' + payload: Record +} + +const TEMPLATE_PRESET_CONFIG: Record = { + operations_default: { + label: 'New Format Template', + kind: 'operations', + payload: OPERATION_TEMPLATE, + }, + legacy_default: { + label: 'Legacy Format Template', + kind: 'legacy', + payload: LEGACY_TEMPLATE, + }, + pass_headers_auth: { + label: 'Header Passthrough (X-Request-Id)', + kind: 'operations', + payload: HEADER_PASSTHROUGH_TEMPLATE, + }, + gemini_image_4k: { + label: 'Gemini Image 4K', + kind: 'operations', + payload: GEMINI_IMAGE_4K_TEMPLATE, + }, + claude_cli_headers_passthrough: { + label: 'Claude CLI Header Passthrough', + kind: 'operations', + payload: CLAUDE_CLI_HEADER_PASSTHROUGH_TEMPLATE, + }, + codex_cli_headers_passthrough: { + label: 'Codex CLI Header Passthrough', + kind: 'operations', + payload: CODEX_CLI_HEADER_PASSTHROUGH_TEMPLATE, + }, + aws_bedrock_anthropic_beta_override: { + label: 'AWS Bedrock Claude Compat', + kind: 'operations', + payload: AWS_BEDROCK_ANTHROPIC_COMPAT_TEMPLATE, + }, +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +let localIdSeed = 0 +const nextLocalId = () => `po_${Date.now()}_${localIdSeed++}` + +const toValueText = (value: unknown): string => { + if (value === undefined) return '' + if (typeof value === 'string') return value + try { + return JSON.stringify(value) + } catch { + return String(value) + } +} + +const parseLooseValue = (valueText: string): unknown => { + const raw = String(valueText ?? '').trim() + if (raw === '') return '' + try { + return JSON.parse(raw) + } catch { + return raw + } +} + +const verifyJSON = (text: string): boolean => { + try { + JSON.parse(text) + return true + } catch { + return false + } +} + +const normalizeCondition = ( + condition: Record = {} +): ParamOverrideCondition => ({ + id: nextLocalId(), + path: typeof condition.path === 'string' ? condition.path : '', + mode: CONDITION_MODE_VALUES.has(condition.mode as string) + ? (condition.mode as string) + : 'full', + value_text: toValueText(condition.value), + invert: condition.invert === true, + pass_missing_key: condition.pass_missing_key === true, +}) + +const createDefaultCondition = (): ParamOverrideCondition => + normalizeCondition({}) + +const normalizeOperation = ( + operation: Record = {} +): ParamOverrideOperation => ({ + id: nextLocalId(), + description: + typeof operation.description === 'string' ? operation.description : '', + path: typeof operation.path === 'string' ? operation.path : '', + mode: OPERATION_MODE_VALUES.has(operation.mode as string) + ? (operation.mode as string) + : 'set', + value_text: toValueText(operation.value), + keep_origin: operation.keep_origin === true, + from: typeof operation.from === 'string' ? operation.from : '', + to: typeof operation.to === 'string' ? operation.to : '', + logic: String(operation.logic || 'OR').toUpperCase() === 'AND' ? 'AND' : 'OR', + conditions: Array.isArray(operation.conditions) + ? (operation.conditions as Record[]).map( + normalizeCondition + ) + : [], +}) + +const createDefaultOperation = (): ParamOverrideOperation => + normalizeOperation({ mode: 'set' }) + +const reorderOperations = ( + ops: ParamOverrideOperation[], + sourceId: string, + targetId: string, + position: 'before' | 'after' = 'before' +): ParamOverrideOperation[] => { + if (!sourceId || !targetId || sourceId === targetId) return ops + const srcIdx = ops.findIndex((o) => o.id === sourceId) + if (srcIdx < 0) return ops + const next = [...ops] + const [moved] = next.splice(srcIdx, 1) + let insertIdx = next.findIndex((o) => o.id === targetId) + if (insertIdx < 0) return ops + if (position === 'after') insertIdx += 1 + next.splice(insertIdx, 0, moved) + return next +} + +const isOperationBlank = (operation: ParamOverrideOperation): boolean => { + const hasCondition = operation.conditions.some( + (c) => + c.path.trim() || + c.value_text.trim() || + c.mode !== 'full' || + c.invert || + c.pass_missing_key + ) + return ( + operation.mode === 'set' && + !operation.path.trim() && + !operation.from.trim() && + !operation.to.trim() && + operation.value_text.trim() === '' && + !operation.keep_origin && + !hasCondition + ) +} + +const getOperationSummary = ( + operation: ParamOverrideOperation, + index: number +): string => { + const mode = operation.mode || 'set' + const modeLabel = OPERATION_MODE_LABEL_MAP[mode] || mode + if (mode === 'sync_fields') { + const from = operation.from.trim() + const to = operation.to.trim() + return `${index + 1}. ${modeLabel} · ${from || to || '-'}` + } + const path = operation.path.trim() + const from = operation.from.trim() + const to = operation.to.trim() + return `${index + 1}. ${modeLabel} · ${path || from || to || '-'}` +} + +const getModeTagTailwind = (mode: string): string => { + if (mode.includes('header')) + return 'bg-cyan-500/15 text-cyan-700 dark:text-cyan-300 border-cyan-500/20' + if (mode.includes('replace') || mode.includes('trim')) + return 'bg-violet-500/15 text-violet-700 dark:text-violet-300 border-violet-500/20' + if (mode.includes('copy') || mode.includes('move')) + return 'bg-blue-500/15 text-blue-700 dark:text-blue-300 border-blue-500/20' + if (mode.includes('error') || mode.includes('prune')) + return 'bg-red-500/15 text-red-700 dark:text-red-300 border-red-500/20' + if (mode.includes('sync')) + return 'bg-green-500/15 text-green-700 dark:text-green-300 border-green-500/20' + return 'bg-muted text-muted-foreground' +} + +const getModePathLabel = (mode: string): string => { + if (mode === 'set_header' || mode === 'delete_header') return 'Header Name' + if (mode === 'prune_objects') return 'Target Path (optional)' + return 'Target Field Path' +} + +const getModePathPlaceholder = (mode: string): string => { + if (mode === 'set_header') return 'Authorization' + if (mode === 'delete_header') return 'X-Debug-Mode' + if (mode === 'prune_objects') return 'messages' + return 'temperature' +} + +const getModeFromLabel = (mode: string): string => { + if (mode === 'replace') return 'Match Text' + if (mode === 'regex_replace') return 'Regex Pattern' + if (mode === 'copy_header' || mode === 'move_header') return 'Source Header' + return 'Source Field' +} + +const getModeFromPlaceholder = (mode: string): string => { + if (mode === 'replace') return 'openai/' + if (mode === 'regex_replace') return '^gpt-' + if (mode === 'copy_header' || mode === 'move_header') return 'Authorization' + return 'model' +} + +const getModeToLabel = (mode: string): string => { + if (mode === 'replace' || mode === 'regex_replace') return 'Replace With' + if (mode === 'copy_header' || mode === 'move_header') return 'Target Header' + return 'Target Field' +} + +const getModeToPlaceholder = (mode: string): string => { + if (mode === 'replace') return '(leave empty to delete)' + if (mode === 'regex_replace') return 'openai/gpt-' + if (mode === 'copy_header' || mode === 'move_header') return 'X-Upstream-Auth' + return 'original_model' +} + +const getModeValueLabel = (mode: string): string => { + if (mode === 'set_header') + return 'Header Value (supports string or JSON mapping)' + if (mode === 'pass_headers') + return 'Pass-through Headers (comma-separated or JSON array)' + if ( + mode === 'trim_prefix' || + mode === 'trim_suffix' || + mode === 'ensure_prefix' || + mode === 'ensure_suffix' + ) + return 'Prefix/Suffix Text' + if (mode === 'prune_objects') return 'Prune Rule (string or JSON object)' + return 'Value (supports JSON or plain text)' +} + +const getModeValuePlaceholder = (mode: string): string => { + if (mode === 'set_header') return 'Bearer sk-xxx' + if (mode === 'pass_headers') return 'Authorization, X-Request-Id' + if ( + mode === 'trim_prefix' || + mode === 'trim_suffix' || + mode === 'ensure_prefix' || + mode === 'ensure_suffix' + ) + return 'openai/' + if (mode === 'prune_objects') return '{"type":"redacted_thinking"}' + return '0.7' +} + +const parseSyncTargetSpec = (spec: string): { type: string; key: string } => { + const raw = String(spec ?? '').trim() + if (!raw) return { type: 'json', key: '' } + const idx = raw.indexOf(':') + if (idx < 0) return { type: 'json', key: raw } + const prefix = raw.slice(0, idx).trim().toLowerCase() + const key = raw.slice(idx + 1).trim() + return prefix === 'header' ? { type: 'header', key } : { type: 'json', key } +} + +const buildSyncTargetSpec = (type: string, key: string): string => { + const normalizedType = type === 'header' ? 'header' : 'json' + const normalizedKey = String(key ?? '').trim() + if (!normalizedKey) return '' + return `${normalizedType}:${normalizedKey}` +} + +// return_error helpers + +type ReturnErrorDraft = { + message: string + statusCode: number + code: string + type: string + skipRetry: boolean + simpleMode: boolean +} + +const parseReturnErrorDraft = (valueText: string): ReturnErrorDraft => { + const defaults: ReturnErrorDraft = { + message: '', + statusCode: 400, + code: '', + type: '', + skipRetry: true, + simpleMode: true, + } + const raw = String(valueText ?? '').trim() + if (!raw) return defaults + try { + const parsed = JSON.parse(raw) as Record + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + const statusRaw = + parsed.status_code !== undefined ? parsed.status_code : parsed.status + const statusValue = Number(statusRaw) + return { + ...defaults, + message: String( + (parsed.message as string) || (parsed.msg as string) || '' + ).trim(), + statusCode: + Number.isInteger(statusValue) && + statusValue >= 100 && + statusValue <= 599 + ? statusValue + : 400, + code: String((parsed.code as string) || '').trim(), + type: String((parsed.type as string) || '').trim(), + skipRetry: parsed.skip_retry !== false, + simpleMode: false, + } + } + } catch { + /* treat as plain text */ + } + return { ...defaults, message: raw, simpleMode: true } +} + +const buildReturnErrorValueText = ( + draft: Partial +): string => { + const message = String(draft.message || '').trim() + if (draft.simpleMode) return message + const statusCode = Number(draft.statusCode) + const payload: Record = { + message, + status_code: + Number.isInteger(statusCode) && statusCode >= 100 && statusCode <= 599 + ? statusCode + : 400, + } + const code = String(draft.code || '').trim() + const type = String(draft.type || '').trim() + if (code) payload.code = code + if (type) payload.type = type + if (draft.skipRetry === false) payload.skip_retry = false + return JSON.stringify(payload) +} + +// prune_objects helpers + +type PruneRule = { + id: string + path: string + mode: string + value_text: string + invert: boolean + pass_missing_key: boolean +} + +type PruneObjectsDraft = { + simpleMode: boolean + typeText: string + logic: string + recursive: boolean + rules: PruneRule[] +} + +const normalizePruneRule = (rule: Record = {}): PruneRule => ({ + id: nextLocalId(), + path: typeof rule.path === 'string' ? rule.path : '', + mode: CONDITION_MODE_VALUES.has(rule.mode as string) + ? (rule.mode as string) + : 'full', + value_text: toValueText(rule.value), + invert: rule.invert === true, + pass_missing_key: rule.pass_missing_key === true, +}) + +const parsePruneObjectsDraft = (valueText: string): PruneObjectsDraft => { + const defaults: PruneObjectsDraft = { + simpleMode: true, + typeText: '', + logic: 'AND', + recursive: true, + rules: [], + } + const raw = String(valueText ?? '').trim() + if (!raw) return defaults + try { + const parsed = JSON.parse(raw) + if (typeof parsed === 'string') + return { ...defaults, typeText: parsed.trim() } + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + const rules: PruneRule[] = [] + if ( + parsed.where && + typeof parsed.where === 'object' && + !Array.isArray(parsed.where) + ) { + for (const [path, value] of Object.entries( + parsed.where as Record + )) { + rules.push(normalizePruneRule({ path, mode: 'full', value })) + } + } + if (Array.isArray(parsed.conditions)) { + for (const item of parsed.conditions) { + if (item && typeof item === 'object') + rules.push(normalizePruneRule(item)) + } + } else if ( + parsed.conditions && + typeof parsed.conditions === 'object' && + !Array.isArray(parsed.conditions) + ) { + for (const [path, value] of Object.entries( + parsed.conditions as Record + )) { + rules.push(normalizePruneRule({ path, mode: 'full', value })) + } + } + const typeText = + parsed.type === undefined ? '' : String(parsed.type).trim() + const logic = + String(parsed.logic || 'AND').toUpperCase() === 'OR' ? 'OR' : 'AND' + const recursive = parsed.recursive !== false + const hasAdvancedFields = + parsed.logic !== undefined || + parsed.recursive !== undefined || + parsed.where !== undefined || + parsed.conditions !== undefined + return { + ...defaults, + simpleMode: !hasAdvancedFields, + typeText, + logic, + recursive, + rules, + } + } + return { ...defaults, typeText: String(parsed ?? '').trim() } + } catch { + return { ...defaults, typeText: raw } + } +} + +const buildPruneObjectsValueText = (draft: PruneObjectsDraft): string => { + const typeText = String(draft.typeText || '').trim() + if (draft.simpleMode) return typeText + const payload: Record = {} + if (typeText) payload.type = typeText + if (String(draft.logic || 'AND').toUpperCase() === 'OR') payload.logic = 'OR' + if (draft.recursive === false) payload.recursive = false + const conditions = (draft.rules || []) + .filter((rule) => String(rule.path || '').trim()) + .map((rule) => { + const conditionPayload: Record = { + path: String(rule.path || '').trim(), + mode: CONDITION_MODE_VALUES.has(rule.mode) ? rule.mode : 'full', + } + const valueRaw = String(rule.value_text || '').trim() + if (valueRaw !== '') conditionPayload.value = parseLooseValue(valueRaw) + if (rule.invert) conditionPayload.invert = true + if (rule.pass_missing_key) conditionPayload.pass_missing_key = true + return conditionPayload + }) + if (conditions.length > 0) payload.conditions = conditions + if (!payload.type && !payload.conditions) + return JSON.stringify({ logic: 'AND' }) + return JSON.stringify(payload) +} + +// pass_headers helpers + +const parsePassHeaderNames = (rawValue: unknown): string[] => { + if (Array.isArray(rawValue)) + return rawValue.map((i) => String(i ?? '').trim()).filter(Boolean) + if (rawValue && typeof rawValue === 'object') { + const obj = rawValue as Record + if (Array.isArray(obj.headers)) + return obj.headers.map((i) => String(i ?? '').trim()).filter(Boolean) + if (obj.header !== undefined) { + const single = String(obj.header ?? '').trim() + return single ? [single] : [] + } + return [] + } + if (typeof rawValue === 'string') + return rawValue + .split(',') + .map((i) => i.trim()) + .filter(Boolean) + return [] +} + +// Condition payload builder +const buildConditionPayload = ( + condition: ParamOverrideCondition +): Record | null => { + const path = condition.path.trim() + if (!path) return null + const payload: Record = { + path, + mode: condition.mode || 'full', + value: parseLooseValue(condition.value_text), + } + if (condition.invert) payload.invert = true + if (condition.pass_missing_key) payload.pass_missing_key = true + return payload +} + +// Validation + +const validateOperations = ( + operations: ParamOverrideOperation[], + t: (key: string, options?: Record) => string +): string => { + for (let i = 0; i < operations.length; i++) { + const op = operations[i] + const mode = op.mode || 'set' + const meta = MODE_META[mode] || MODE_META.set + const line = i + 1 + const pathValue = op.path.trim() + const fromValue = op.from.trim() + const toValue = op.to.trim() + + if (meta.path && !pathValue) + return t('Rule {{line}} is missing target path', { line }) + if (FROM_REQUIRED_MODES.has(mode) && !fromValue) { + if (!(meta.pathAlias && pathValue)) + return t('Rule {{line}} is missing source field', { line }) + } + if (TO_REQUIRED_MODES.has(mode) && !toValue) { + if (!(meta.pathAlias && pathValue)) + return t('Rule {{line}} is missing target field', { line }) + } + if (VALUE_REQUIRED_MODES.has(mode) && op.value_text.trim() === '') + return t('Rule {{line}} is missing value', { line }) + + if (mode === 'return_error') { + const raw = op.value_text.trim() + if (!raw) return t('Rule {{line}} is missing value', { line }) + try { + const parsed = JSON.parse(raw) + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + if (!String((parsed as Record).message || '').trim()) + return t('Rule {{line}} return_error requires a message field', { + line, + }) + } + } catch { + /* plain string is allowed */ + } + } + + if (mode === 'prune_objects') { + const raw = op.value_text.trim() + if (!raw) + return t('Rule {{line}} prune_objects is missing conditions', { line }) + } + + if (mode === 'pass_headers') { + const raw = op.value_text.trim() + if (!raw) + return t('Rule {{line}} pass_headers is missing header names', { line }) + const parsed = parseLooseValue(raw) + const headers = parsePassHeaderNames(parsed) + if (headers.length === 0) + return t('Rule {{line}} pass_headers format is invalid', { line }) + } + } + return '' +} + +// Parse initial state + +type EditorState = { + editMode: 'visual' | 'json' + visualMode: 'operations' | 'legacy' + legacyValue: string + operations: ParamOverrideOperation[] + jsonText: string + jsonError: string +} + +const parseInitialState = (rawValue: string): EditorState => { + const text = typeof rawValue === 'string' ? rawValue : '' + const trimmed = text.trim() + if (!trimmed) { + return { + editMode: 'visual', + visualMode: 'operations', + legacyValue: '', + operations: [createDefaultOperation()], + jsonText: '', + jsonError: '', + } + } + + if (!verifyJSON(trimmed)) { + return { + editMode: 'json', + visualMode: 'operations', + legacyValue: '', + operations: [createDefaultOperation()], + jsonText: text, + jsonError: 'Invalid JSON format', + } + } + + const parsed = JSON.parse(trimmed) as Record + const pretty = JSON.stringify(parsed, null, 2) + + if ( + parsed && + typeof parsed === 'object' && + !Array.isArray(parsed) && + Array.isArray(parsed.operations) + ) { + return { + editMode: 'visual', + visualMode: 'operations', + legacyValue: '', + operations: + (parsed.operations as Record[]).length > 0 + ? (parsed.operations as Record[]).map( + normalizeOperation + ) + : [createDefaultOperation()], + jsonText: pretty, + jsonError: '', + } + } + + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return { + editMode: 'visual', + visualMode: 'legacy', + legacyValue: pretty, + operations: [createDefaultOperation()], + jsonText: pretty, + jsonError: '', + } + } + + return { + editMode: 'json', + visualMode: 'operations', + legacyValue: '', + operations: [createDefaultOperation()], + jsonText: pretty, + jsonError: '', + } +} + +// Build operations JSON + +const buildOperationsJson = ( + sourceOperations: ParamOverrideOperation[], + options: { validate: boolean }, + t: (key: string, options?: Record) => string +): string => { + const filteredOps = sourceOperations.filter((o) => !isOperationBlank(o)) + if (filteredOps.length === 0) return '' + + if (options.validate) { + const message = validateOperations(filteredOps, t) + if (message) throw new Error(message) + } + + const payloadOps = filteredOps.map((operation) => { + const mode = operation.mode || 'set' + const meta = MODE_META[mode] || MODE_META.set + const descriptionValue = String(operation.description || '').trim() + const pathValue = operation.path.trim() + const fromValue = operation.from.trim() + const toValue = operation.to.trim() + const payload: Record = { mode } + if (descriptionValue) payload.description = descriptionValue + if (meta.path) payload.path = pathValue + if (meta.pathOptional && pathValue) payload.path = pathValue + if (meta.value) payload.value = parseLooseValue(operation.value_text) + if (meta.keepOrigin && operation.keep_origin) payload.keep_origin = true + if (meta.from) payload.from = fromValue + if (!meta.to && operation.to.trim()) payload.to = toValue + if (meta.to) payload.to = toValue + if (meta.pathAlias) { + if (!payload.from && pathValue) payload.from = pathValue + if (!payload.to && pathValue) payload.to = pathValue + } + const conditions = operation.conditions + .map(buildConditionPayload) + .filter(Boolean) + if (conditions.length > 0) { + payload.conditions = conditions + payload.logic = operation.logic === 'AND' ? 'AND' : 'OR' + } + return payload + }) + + return JSON.stringify({ operations: payloadOps }, null, 2) +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export function ParamOverrideEditorDialog( + props: ParamOverrideEditorDialogProps +) { + const { t } = useTranslation() + + const [editMode, setEditMode] = useState<'visual' | 'json'>('visual') + const [visualMode, setVisualMode] = useState<'operations' | 'legacy'>( + 'operations' + ) + const [legacyValue, setLegacyValue] = useState('') + const [operations, setOperations] = useState([ + createDefaultOperation(), + ]) + const [jsonText, setJsonText] = useState('') + const [jsonError, setJsonError] = useState('') + const [operationSearch, setOperationSearch] = useState('') + const [selectedOperationId, setSelectedOperationId] = useState('') + const [expandedConditions, setExpandedConditions] = useState< + Record + >({}) + const [draggedOperationId, setDraggedOperationId] = useState('') + const [dragOverOperationId, setDragOverOperationId] = useState('') + const [dragOverPosition, setDragOverPosition] = useState<'before' | 'after'>( + 'before' + ) + const [templatePresetKey, setTemplatePresetKey] = + useState('operations_default') + + // Initialize state when dialog opens + useEffect(() => { + if (!props.open) return + const state = parseInitialState(props.value) + setEditMode(state.editMode) + setVisualMode(state.visualMode) + setLegacyValue(state.legacyValue) + setOperations(state.operations) + setJsonText(state.jsonText) + setJsonError(state.jsonError) + setOperationSearch('') + setSelectedOperationId(state.operations[0]?.id || '') + setExpandedConditions({}) + setDraggedOperationId('') + setDragOverOperationId('') + setDragOverPosition('before') + if (state.visualMode === 'legacy') { + setTemplatePresetKey('legacy_default') + } else { + setTemplatePresetKey('operations_default') + } + }, [props.open, props.value]) + + // Keep selectedOperationId valid + useEffect(() => { + if (operations.length === 0) { + setSelectedOperationId('') + return + } + if (!operations.some((o) => o.id === selectedOperationId)) { + setSelectedOperationId(operations[0].id) + } + }, [operations, selectedOperationId]) + + // Template preset options filtered by group + const templatePresetOptions = useMemo( + () => + Object.entries(TEMPLATE_PRESET_CONFIG).map(([value, config]) => ({ + value, + label: config.label, + })), + [] + ) + + const operationCount = useMemo( + () => operations.filter((o) => !isOperationBlank(o)).length, + [operations] + ) + + const filteredOperations = useMemo(() => { + const keyword = operationSearch.trim().toLowerCase() + if (!keyword) return operations + return operations.filter((op) => { + const searchableText = [ + op.description, + op.mode, + op.path, + op.from, + op.to, + op.value_text, + ] + .filter(Boolean) + .join(' ') + .toLowerCase() + return searchableText.includes(keyword) + }) + }, [operationSearch, operations]) + + const selectedOperation = useMemo( + () => operations.find((o) => o.id === selectedOperationId), + [operations, selectedOperationId] + ) + + const selectedOperationIndex = useMemo( + () => operations.findIndex((o) => o.id === selectedOperationId), + [operations, selectedOperationId] + ) + + const returnErrorDraft = useMemo(() => { + if (!selectedOperation || selectedOperation.mode !== 'return_error') + return null + return parseReturnErrorDraft(selectedOperation.value_text) + }, [selectedOperation]) + + const pruneObjectsDraft = useMemo(() => { + if (!selectedOperation || selectedOperation.mode !== 'prune_objects') + return null + return parsePruneObjectsDraft(selectedOperation.value_text) + }, [selectedOperation]) + + const topOperationModes = useMemo(() => { + const counts: Record = {} + for (const op of operations) { + const mode = op.mode || 'set' + counts[mode] = (counts[mode] || 0) + 1 + } + return Object.entries(counts) + .sort((a, b) => b[1] - a[1]) + .slice(0, 4) + }, [operations]) + + // --------------------------------------------------------------------------- + // Operations + // --------------------------------------------------------------------------- + + const updateOperation = useCallback( + (operationId: string, patch: Partial) => { + setOperations((prev) => + prev.map((o) => (o.id === operationId ? { ...o, ...patch } : o)) + ) + }, + [] + ) + + const addOperation = useCallback(() => { + const created = createDefaultOperation() + setOperations((prev) => [...prev, created]) + setSelectedOperationId(created.id) + }, []) + + const duplicateOperation = useCallback((operationId: string) => { + let insertedId = '' + setOperations((prev) => { + const idx = prev.findIndex((o) => o.id === operationId) + if (idx < 0) return prev + const source = prev[idx] + const cloned = normalizeOperation({ + description: source.description, + path: source.path, + mode: source.mode, + value: parseLooseValue(source.value_text), + keep_origin: source.keep_origin, + from: source.from, + to: source.to, + logic: source.logic, + conditions: source.conditions.map((c) => ({ + path: c.path, + mode: c.mode, + value: parseLooseValue(c.value_text), + invert: c.invert, + pass_missing_key: c.pass_missing_key, + })), + }) + insertedId = cloned.id + const next = [...prev] + next.splice(idx + 1, 0, cloned) + return next + }) + if (insertedId) setSelectedOperationId(insertedId) + }, []) + + const removeOperation = useCallback((operationId: string) => { + setOperations((prev) => { + if (prev.length <= 1) return [createDefaultOperation()] + return prev.filter((o) => o.id !== operationId) + }) + }, []) + + // Conditions + const addCondition = useCallback((operationId: string) => { + const created = createDefaultCondition() + setOperations((prev) => + prev.map((op) => + op.id === operationId + ? { ...op, conditions: [...op.conditions, created] } + : op + ) + ) + setExpandedConditions((prev) => ({ ...prev, [created.id]: true })) + }, []) + + const updateCondition = useCallback( + ( + operationId: string, + conditionId: string, + patch: Partial + ) => { + setOperations((prev) => + prev.map((op) => + op.id === operationId + ? { + ...op, + conditions: op.conditions.map((c) => + c.id === conditionId ? { ...c, ...patch } : c + ), + } + : op + ) + ) + }, + [] + ) + + const removeCondition = useCallback( + (operationId: string, conditionId: string) => { + setOperations((prev) => + prev.map((op) => + op.id === operationId + ? { + ...op, + conditions: op.conditions.filter((c) => c.id !== conditionId), + } + : op + ) + ) + }, + [] + ) + + // return_error draft + const updateReturnErrorDraft = useCallback( + (operationId: string, draftPatch: Partial) => { + setOperations((prev) => + prev.map((op) => { + if (op.id !== operationId) return op + const draft = parseReturnErrorDraft(op.value_text) + const nextDraft = { ...draft, ...draftPatch } + return { + ...op, + value_text: buildReturnErrorValueText(nextDraft), + } + }) + ) + }, + [] + ) + + // prune_objects draft + const updatePruneObjectsDraft = useCallback( + ( + operationId: string, + updater: + | Partial + | ((draft: PruneObjectsDraft) => PruneObjectsDraft) + ) => { + setOperations((prev) => + prev.map((op) => { + if (op.id !== operationId) return op + const draft = parsePruneObjectsDraft(op.value_text) + const nextDraft = + typeof updater === 'function' + ? updater(draft) + : { ...draft, ...updater } + return { + ...op, + value_text: buildPruneObjectsValueText(nextDraft), + } + }) + ) + }, + [] + ) + + const addPruneRule = useCallback( + (operationId: string) => { + updatePruneObjectsDraft(operationId, (draft) => ({ + ...draft, + simpleMode: false, + rules: [...draft.rules, normalizePruneRule({})], + })) + }, + [updatePruneObjectsDraft] + ) + + const updatePruneRule = useCallback( + (operationId: string, ruleId: string, patch: Partial) => { + updatePruneObjectsDraft(operationId, (draft) => ({ + ...draft, + rules: draft.rules.map((r) => + r.id === ruleId ? { ...r, ...patch } : r + ), + })) + }, + [updatePruneObjectsDraft] + ) + + const removePruneRule = useCallback( + (operationId: string, ruleId: string) => { + updatePruneObjectsDraft(operationId, (draft) => ({ + ...draft, + rules: draft.rules.filter((r) => r.id !== ruleId), + })) + }, + [updatePruneObjectsDraft] + ) + + // Drag and drop + const resetDragState = useCallback(() => { + setDraggedOperationId('') + setDragOverOperationId('') + setDragOverPosition('before') + }, []) + + const handleDragStart = useCallback( + (event: DragEvent, operationId: string) => { + setDraggedOperationId(operationId) + setSelectedOperationId(operationId) + event.dataTransfer.effectAllowed = 'move' + event.dataTransfer.setData('text/plain', operationId) + }, + [] + ) + + const handleDragOver = useCallback( + (event: DragEvent, operationId: string) => { + event.preventDefault() + if (!draggedOperationId || draggedOperationId === operationId) return + const rect = event.currentTarget.getBoundingClientRect() + const position: 'before' | 'after' = + event.clientY - rect.top > rect.height / 2 ? 'after' : 'before' + setDragOverOperationId(operationId) + setDragOverPosition(position) + event.dataTransfer.dropEffect = 'move' + }, + [draggedOperationId] + ) + + const handleDrop = useCallback( + (event: DragEvent, operationId: string) => { + event.preventDefault() + const sourceId = + draggedOperationId || event.dataTransfer.getData('text/plain') + const position = + dragOverOperationId === operationId ? dragOverPosition : 'before' + if (sourceId && operationId && sourceId !== operationId) { + setOperations((prev) => + reorderOperations(prev, sourceId, operationId, position) + ) + setSelectedOperationId(sourceId) + } + resetDragState() + }, + [draggedOperationId, dragOverOperationId, dragOverPosition, resetDragState] + ) + + // --------------------------------------------------------------------------- + // Mode switching & templates + // --------------------------------------------------------------------------- + + const buildVisualJson = useCallback((): string => { + if (visualMode === 'legacy') { + const trimmed = legacyValue.trim() + if (!trimmed) return '' + if (!verifyJSON(trimmed)) + throw new Error(t('Parameter override must be valid JSON format')) + const parsed = JSON.parse(trimmed) as unknown + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) + throw new Error(t('Legacy format must be a JSON object')) + return JSON.stringify(parsed, null, 2) + } + return buildOperationsJson(operations, { validate: true }, t) + }, [legacyValue, operations, t, visualMode]) + + const switchToJsonMode = useCallback(() => { + if (editMode === 'json') return + try { + setJsonText(buildVisualJson()) + setJsonError('') + } catch (error) { + toast.error((error as Error).message) + if (visualMode === 'legacy') { + setJsonText(legacyValue) + } else { + setJsonText(buildOperationsJson(operations, { validate: false }, t)) + } + setJsonError( + (error as Error).message || t('Parameter configuration error') + ) + } + setEditMode('json') + }, [buildVisualJson, editMode, legacyValue, operations, t, visualMode]) + + const switchToVisualMode = useCallback(() => { + if (editMode === 'visual') return + const trimmed = jsonText.trim() + if (!trimmed) { + const fallback = createDefaultOperation() + setVisualMode('operations') + setOperations([fallback]) + setSelectedOperationId(fallback.id) + setLegacyValue('') + setJsonError('') + setEditMode('visual') + return + } + if (!verifyJSON(trimmed)) { + toast.error(t('Parameter override must be valid JSON format')) + return + } + const parsed = JSON.parse(trimmed) as Record + if ( + parsed && + typeof parsed === 'object' && + !Array.isArray(parsed) && + Array.isArray(parsed.operations) + ) { + const nextOps = + (parsed.operations as Record[]).length > 0 + ? (parsed.operations as Record[]).map( + normalizeOperation + ) + : [createDefaultOperation()] + setVisualMode('operations') + setOperations(nextOps) + setSelectedOperationId(nextOps[0]?.id || '') + setLegacyValue('') + setJsonError('') + setEditMode('visual') + setTemplatePresetKey('operations_default') + return + } + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + const fallback = createDefaultOperation() + setVisualMode('legacy') + setLegacyValue(JSON.stringify(parsed, null, 2)) + setOperations([fallback]) + setSelectedOperationId(fallback.id) + setJsonError('') + setEditMode('visual') + setTemplatePresetKey('legacy_default') + return + } + toast.error(t('Parameter override must be a valid JSON object')) + }, [editMode, jsonText, t]) + + const fillTemplate = useCallback( + (mode: 'fill' | 'append') => { + const preset = + TEMPLATE_PRESET_CONFIG[templatePresetKey] || + TEMPLATE_PRESET_CONFIG.operations_default + const payload = preset.payload as Record + + if (preset.kind === 'legacy') { + if (mode === 'append' && visualMode === 'legacy') { + const trimmed = legacyValue.trim() + let parsedCurrent: Record = {} + if (trimmed) { + if (!verifyJSON(trimmed)) { + toast.error(t('Current legacy JSON is invalid, cannot append')) + return + } + parsedCurrent = JSON.parse(trimmed) as Record + } + const merged = { ...(payload || {}), ...parsedCurrent } + const text = JSON.stringify(merged, null, 2) + setVisualMode('legacy') + setLegacyValue(text) + setOperations([createDefaultOperation()]) + setJsonText(text) + setJsonError('') + setEditMode('visual') + } else { + const text = JSON.stringify(payload, null, 2) + setVisualMode('legacy') + setLegacyValue(text) + setOperations([createDefaultOperation()]) + setJsonText(text) + setJsonError('') + setEditMode('visual') + } + return + } + + const operationsPayload = ((payload as Record) + .operations || []) as Record[] + + if (mode === 'append') { + const appended = operationsPayload.map(normalizeOperation) + const existing = + visualMode === 'operations' + ? operations.filter((o) => !isOperationBlank(o)) + : [] + const nextOps = [...existing, ...appended] + setVisualMode('operations') + setOperations(nextOps.length > 0 ? nextOps : appended) + setSelectedOperationId(nextOps[0]?.id || appended[0]?.id || '') + setLegacyValue('') + setJsonError('') + setEditMode('visual') + setJsonText('') + } else { + const nextOps = operationsPayload.map(normalizeOperation) + const finalOps = + nextOps.length > 0 ? nextOps : [createDefaultOperation()] + setVisualMode('operations') + setOperations(finalOps) + setSelectedOperationId(finalOps[0]?.id || '') + setJsonText(JSON.stringify({ operations: operationsPayload }, null, 2)) + setJsonError('') + setEditMode('visual') + } + }, + [legacyValue, operations, templatePresetKey, visualMode, t] + ) + + const resetEditorState = useCallback(() => { + const fallback = createDefaultOperation() + setVisualMode('operations') + setLegacyValue('') + setOperations([fallback]) + setSelectedOperationId(fallback.id) + setJsonText('') + setJsonError('') + setTemplatePresetKey('operations_default') + setEditMode('visual') + }, []) + + // JSON mode + const handleJsonChange = useCallback( + (nextValue: string) => { + setJsonText(nextValue) + const trimmed = nextValue.trim() + if (!trimmed) { + setJsonError('') + return + } + setJsonError(verifyJSON(trimmed) ? '' : t('JSON format error')) + }, + [t] + ) + + const formatJson = useCallback(() => { + const trimmed = jsonText.trim() + if (!trimmed) return + if (!verifyJSON(trimmed)) { + toast.error(t('Parameter override must be valid JSON format')) + return + } + setJsonText(JSON.stringify(JSON.parse(trimmed), null, 2)) + setJsonError('') + }, [jsonText, t]) + + const visualValidationError = useMemo(() => { + if (editMode !== 'visual') return '' + try { + buildVisualJson() + return '' + } catch (error) { + return (error as Error)?.message || t('Parameter configuration error') + } + }, [buildVisualJson, editMode, t]) + + // Save + const handleSave = useCallback(() => { + try { + let result = '' + if (editMode === 'json') { + const trimmed = jsonText.trim() + if (trimmed) { + if (!verifyJSON(trimmed)) + throw new Error(t('Parameter override must be valid JSON format')) + result = JSON.stringify(JSON.parse(trimmed), null, 2) + } + } else { + result = buildVisualJson() + } + props.onSave(result) + props.onOpenChange(false) + } catch (error) { + toast.error((error as Error).message) + } + }, [buildVisualJson, editMode, jsonText, props, t]) + + // Expand/collapse all conditions + const expandAllConditions = useCallback(() => { + if (!selectedOperation) return + const map: Record = {} + for (const c of selectedOperation.conditions) map[c.id] = true + setExpandedConditions((prev) => ({ ...prev, ...map })) + }, [selectedOperation]) + + const collapseAllConditions = useCallback(() => { + if (!selectedOperation) return + const map: Record = {} + for (const c of selectedOperation.conditions) map[c.id] = false + setExpandedConditions((prev) => ({ ...prev, ...map })) + }, [selectedOperation]) + + // --------------------------------------------------------------------------- + // Render + // --------------------------------------------------------------------------- + + return ( + + + + {t('Parameter Override')} + + {t( + 'Create request parameter override rules with a visual editor or raw JSON.' + )} + + + + {/* Toolbar */} +
+
+ + {t('Mode')} + + + + +
+ + + {t('Template')} + + + + + +
+
+ + {/* Content */} +
+ {editMode === 'visual' ? ( + visualMode === 'legacy' ? ( +
+

+ {t('Legacy Format (JSON Object)')} +

+