Files
Cli-Proxy-API-Management-Ce…/src/components/config/VisualConfigEditorBlocks.tsx
T
Xvvln bcaf637253 feat(config): add expandable input for long payload override values
- Add ExpandableInput component that toggles between single-line <input>
  and auto-resizing <textarea> for long content (>30 chars)
- Apply to all payload rule fields: model name (both layout branches),
  json_path, param value, filter model name, and string list items
- Strip newline characters in both input and textarea onChange handlers
  to prevent multi-line values from breaking YAML serialization
- Use useLayoutEffect for resize to avoid visual flicker
- Remove redundant autoResize call from click handler (handled by effect)
- Disable expand/collapse buttons when field is disabled, with proper
  cursor and opacity styles
- Localize button labels via i18n (en, zh-CN, ru)
- Use explicit .expandableInputExpanded class instead of CSS :has()
  for cross-browser compatibility
2026-03-22 18:52:32 +08:00

937 lines
32 KiB
TypeScript

import { memo, useCallback, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
import { Select } from '@/components/ui/Select';
import { useNotificationStore } from '@/stores';
import styles from './VisualConfigEditor.module.scss';
import { copyToClipboard } from '@/utils/clipboard';
import type {
PayloadFilterRule,
PayloadModelEntry,
PayloadParamEntry,
PayloadParamValidationErrorCode,
PayloadParamValueType,
PayloadRule,
} from '@/types/visualConfig';
import { makeClientId } from '@/types/visualConfig';
import {
getPayloadParamValidationError,
VISUAL_CONFIG_PAYLOAD_VALUE_TYPE_OPTIONS,
VISUAL_CONFIG_PROTOCOL_OPTIONS,
} from '@/hooks/useVisualConfig';
import { maskApiKey } from '@/utils/format';
import { isValidApiKeyCharset } from '@/utils/validation';
/** Minimum character count before the expand/collapse toggle appears. */
const EXPAND_THRESHOLD = 30;
/** Auto-expanding textarea that collapses back to a single-line input on demand. */
function ExpandableInput({
value,
placeholder,
ariaLabel,
disabled,
className,
onChange,
}: {
value: string;
placeholder?: string;
ariaLabel?: string;
disabled?: boolean;
className?: string;
onChange: (nextValue: string) => void;
}) {
const { t } = useTranslation();
const [collapsed, setCollapsed] = useState(true);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const autoResize = useCallback((el: HTMLTextAreaElement) => {
el.style.height = 'auto';
el.style.height = `${el.scrollHeight}px`;
}, []);
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
// Strip newlines — these fields are single-line identifiers/paths that
// would break YAML serialization if they contained line breaks.
const sanitized = e.target.value.replace(/[\r\n]/g, '');
onChange(sanitized);
// autoResize is handled by useLayoutEffect after React syncs the
// sanitized value back to the DOM — calling it here would measure
// stale content.
};
// Resize synchronously before paint to avoid visual flicker.
useLayoutEffect(() => {
if (!collapsed && textareaRef.current) {
autoResize(textareaRef.current);
}
}, [collapsed, value, autoResize]);
if (collapsed) {
return (
<div className={styles.expandableInputWrapper}>
<input
className={`input ${className ?? ''}`}
placeholder={placeholder}
aria-label={ariaLabel}
value={value}
onChange={(e) => onChange(e.target.value.replace(/[\r\n]/g, ''))}
disabled={disabled}
/>
{value.length > EXPAND_THRESHOLD && (
<button
type="button"
className={styles.expandableToggle}
disabled={disabled}
onClick={() => {
setCollapsed(false);
requestAnimationFrame(() => {
textareaRef.current?.focus();
});
}}
title={t('common.expand')}
aria-label={t('common.expand')}
>
</button>
)}
</div>
);
}
return (
<div className={`${styles.expandableInputWrapper} ${styles.expandableInputExpanded}`}>
<textarea
ref={textareaRef}
className={`input ${styles.expandableTextarea} ${className ?? ''}`}
placeholder={placeholder}
aria-label={ariaLabel}
value={value}
onChange={handleChange}
disabled={disabled}
rows={2}
/>
<button
type="button"
className={styles.expandableToggle}
disabled={disabled}
onClick={() => setCollapsed(true)}
title={t('common.collapse')}
aria-label={t('common.collapse')}
>
</button>
</div>
);
}
function getValidationMessage(
t: ReturnType<typeof useTranslation>['t'],
errorCode?: PayloadParamValidationErrorCode
) {
if (!errorCode) return undefined;
return t(`config_management.visual.validation.${errorCode}`);
}
function buildProtocolOptions(
t: ReturnType<typeof useTranslation>['t'],
rules: Array<{ models: PayloadModelEntry[] }>
) {
const options: Array<{ value: string; label: string }> = VISUAL_CONFIG_PROTOCOL_OPTIONS.map(
(option) => ({
value: option.value,
label: t(option.labelKey, { defaultValue: option.defaultLabel }),
})
);
const seen = new Set<string>(options.map((option) => option.value));
for (const rule of rules) {
for (const model of rule.models) {
const protocol = model.protocol;
if (!protocol || !protocol.trim() || seen.has(protocol)) continue;
seen.add(protocol);
options.push({ value: protocol, label: protocol });
}
}
return options;
}
export const ApiKeysCardEditor = memo(function ApiKeysCardEditor({
value,
disabled,
onChange,
}: {
value: string;
disabled?: boolean;
onChange: (nextValue: string) => void;
}) {
const { t } = useTranslation();
const showNotification = useNotificationStore((state) => state.showNotification);
const apiKeys = useMemo(
() =>
value
.split('\n')
.map((key) => key.trim())
.filter(Boolean),
[value]
);
const [apiKeyIds, setApiKeyIds] = useState(() => apiKeys.map(() => makeClientId()));
const renderApiKeyIds = useMemo(() => {
if (apiKeyIds.length === apiKeys.length) return apiKeyIds;
if (apiKeyIds.length > apiKeys.length) return apiKeyIds.slice(0, apiKeys.length);
return [
...apiKeyIds,
...Array.from({ length: apiKeys.length - apiKeyIds.length }, () => makeClientId()),
];
}, [apiKeyIds, apiKeys.length]);
const apiKeyInputId = useId();
const apiKeyHintId = `${apiKeyInputId}-hint`;
const apiKeyErrorId = `${apiKeyInputId}-error`;
const [modalOpen, setModalOpen] = useState(false);
const [editingApiKeyId, setEditingApiKeyId] = useState<string | null>(null);
const [inputValue, setInputValue] = useState('');
const [formError, setFormError] = useState('');
function generateSecureApiKey(): string {
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const array = new Uint8Array(17);
crypto.getRandomValues(array);
return 'sk-' + Array.from(array, (b) => charset[b % charset.length]).join('');
}
const openAddModal = () => {
setEditingApiKeyId(null);
setInputValue('');
setFormError('');
setModalOpen(true);
};
const openEditModal = (apiKeyId: string) => {
const editingIndex = renderApiKeyIds.findIndex((id) => id === apiKeyId);
setEditingApiKeyId(apiKeyId);
setInputValue(apiKeys[editingIndex] ?? '');
setFormError('');
setModalOpen(true);
};
const closeModal = () => {
setModalOpen(false);
setInputValue('');
setEditingApiKeyId(null);
setFormError('');
};
const updateApiKeys = (nextKeys: string[]) => {
onChange(nextKeys.join('\n'));
};
const handleDelete = (apiKeyId: string) => {
const index = renderApiKeyIds.findIndex((id) => id === apiKeyId);
if (index < 0) return;
setApiKeyIds(renderApiKeyIds.filter((id) => id !== apiKeyId));
updateApiKeys(apiKeys.filter((_, i) => i !== index));
};
const handleSave = () => {
const trimmed = inputValue.trim();
if (!trimmed) {
setFormError(t('config_management.visual.api_keys.error_empty'));
return;
}
if (!isValidApiKeyCharset(trimmed)) {
setFormError(t('config_management.visual.api_keys.error_invalid'));
return;
}
const editingIndex = editingApiKeyId
? renderApiKeyIds.findIndex((id) => id === editingApiKeyId)
: -1;
const nextKeys =
editingApiKeyId === null
? [...apiKeys, trimmed]
: apiKeys.map((key, idx) => (idx === editingIndex ? trimmed : key));
if (editingApiKeyId === null) {
setApiKeyIds([...renderApiKeyIds, makeClientId()]);
}
updateApiKeys(nextKeys);
closeModal();
};
const handleCopy = async (apiKey: string) => {
const copied = await copyToClipboard(apiKey);
showNotification(
t(copied ? 'notification.link_copied' : 'notification.copy_failed'),
copied ? 'success' : 'error'
);
};
const handleGenerate = () => {
setInputValue(generateSecureApiKey());
setFormError('');
};
return (
<div className="form-group" style={{ marginBottom: 0 }}>
<div className={styles.blockHeaderRow}>
<label style={{ margin: 0 }}>{t('config_management.visual.api_keys.label')}</label>
<Button size="sm" onClick={openAddModal} disabled={disabled}>
{t('config_management.visual.api_keys.add')}
</Button>
</div>
{apiKeys.length === 0 ? (
<div className={styles.emptyState}>{t('config_management.visual.api_keys.empty')}</div>
) : (
<div className="item-list" style={{ marginTop: 4 }}>
{apiKeys.map((key, index) => (
<div key={renderApiKeyIds[index] ?? `${key}-${index}`} className="item-row">
<div className="item-meta">
<div className="pill">#{index + 1}</div>
<div className="item-title">
{t('config_management.visual.api_keys.input_label')}
</div>
<div className="item-subtitle">{maskApiKey(String(key || ''))}</div>
</div>
<div className="item-actions">
<Button
variant="secondary"
size="sm"
onClick={() => handleCopy(key)}
disabled={disabled}
>
{t('common.copy')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => openEditModal(renderApiKeyIds[index] ?? '')}
disabled={disabled}
>
{t('config_management.visual.common.edit')}
</Button>
<Button
variant="danger"
size="sm"
onClick={() => handleDelete(renderApiKeyIds[index] ?? '')}
disabled={disabled}
>
{t('config_management.visual.common.delete')}
</Button>
</div>
</div>
))}
</div>
)}
<div className="hint">{t('config_management.visual.api_keys.hint')}</div>
<Modal
open={modalOpen}
onClose={closeModal}
title={
editingApiKeyId !== null
? t('config_management.visual.api_keys.edit_title')
: t('config_management.visual.api_keys.add_title')
}
footer={
<>
<Button variant="secondary" onClick={closeModal} disabled={disabled}>
{t('config_management.visual.common.cancel')}
</Button>
<Button onClick={handleSave} disabled={disabled}>
{editingApiKeyId !== null
? t('config_management.visual.common.update')
: t('config_management.visual.common.add')}
</Button>
</>
}
>
<div className="form-group">
<label htmlFor={apiKeyInputId}>
{t('config_management.visual.api_keys.input_label')}
</label>
<div className={styles.apiKeyModalInputRow}>
<input
id={apiKeyInputId}
className="input"
placeholder={t('config_management.visual.api_keys.input_placeholder')}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
disabled={disabled}
aria-describedby={formError ? `${apiKeyErrorId} ${apiKeyHintId}` : apiKeyHintId}
aria-invalid={Boolean(formError)}
/>
<Button
type="button"
variant="secondary"
size="sm"
onClick={handleGenerate}
disabled={disabled}
>
{t('config_management.visual.api_keys.generate')}
</Button>
</div>
<div id={apiKeyHintId} className="hint">
{t('config_management.visual.api_keys.input_hint')}
</div>
{formError && (
<div id={apiKeyErrorId} className="error-box">
{formError}
</div>
)}
</div>
</Modal>
</div>
);
});
const StringListEditor = memo(function StringListEditor({
value,
disabled,
placeholder,
inputAriaLabel,
onChange,
}: {
value: string[];
disabled?: boolean;
placeholder?: string;
inputAriaLabel?: string;
onChange: (next: string[]) => void;
}) {
const { t } = useTranslation();
const items = value.length ? value : [];
const [itemIds, setItemIds] = useState(() => items.map(() => makeClientId()));
const renderItemIds = useMemo(() => {
if (itemIds.length === items.length) return itemIds;
if (itemIds.length > items.length) return itemIds.slice(0, items.length);
return [
...itemIds,
...Array.from({ length: items.length - itemIds.length }, () => makeClientId()),
];
}, [itemIds, items.length]);
const updateItem = (index: number, nextValue: string) =>
onChange(items.map((item, i) => (i === index ? nextValue : item)));
const addItem = () => {
setItemIds([...renderItemIds, makeClientId()]);
onChange([...items, '']);
};
const removeItem = (index: number) => {
setItemIds(renderItemIds.filter((_, i) => i !== index));
onChange(items.filter((_, i) => i !== index));
};
return (
<div className={styles.stringList}>
{items.map((item, index) => (
<div key={renderItemIds[index] ?? `item-${index}`} className={styles.stringListRow}>
<ExpandableInput
placeholder={placeholder}
ariaLabel={inputAriaLabel ?? placeholder}
value={item}
onChange={(nextValue) => updateItem(index, nextValue)}
disabled={disabled}
/>
<Button variant="ghost" size="sm" onClick={() => removeItem(index)} disabled={disabled}>
{t('config_management.visual.common.delete')}
</Button>
</div>
))}
<div className={styles.actionRow}>
<Button variant="secondary" size="sm" onClick={addItem} disabled={disabled}>
{t('config_management.visual.common.add')}
</Button>
</div>
</div>
);
});
export const PayloadRulesEditor = memo(function PayloadRulesEditor({
value,
disabled,
protocolFirst = false,
rawJsonValues = false,
onChange,
}: {
value: PayloadRule[];
disabled?: boolean;
protocolFirst?: boolean;
rawJsonValues?: boolean;
onChange: (next: PayloadRule[]) => void;
}) {
const { t } = useTranslation();
const rules = value;
const protocolOptions = useMemo(() => buildProtocolOptions(t, rules), [rules, t]);
const payloadValueTypeOptions = useMemo(
() =>
VISUAL_CONFIG_PAYLOAD_VALUE_TYPE_OPTIONS.map((option) => ({
value: option.value,
label: t(option.labelKey, { defaultValue: option.defaultLabel }),
})),
[t]
);
const booleanValueOptions = useMemo(
() => [
{ value: 'true', label: t('config_management.visual.payload_rules.boolean_true') },
{ value: 'false', label: t('config_management.visual.payload_rules.boolean_false') },
],
[t]
);
const addRule = () => onChange([...rules, { id: makeClientId(), models: [], params: [] }]);
const removeRule = (ruleIndex: number) => onChange(rules.filter((_, i) => i !== ruleIndex));
const updateRule = (ruleIndex: number, patch: Partial<PayloadRule>) =>
onChange(rules.map((rule, i) => (i === ruleIndex ? { ...rule, ...patch } : rule)));
const addModel = (ruleIndex: number) => {
const rule = rules[ruleIndex];
const nextModel: PayloadModelEntry = { id: makeClientId(), name: '', protocol: undefined };
updateRule(ruleIndex, { models: [...rule.models, nextModel] });
};
const removeModel = (ruleIndex: number, modelIndex: number) => {
const rule = rules[ruleIndex];
updateRule(ruleIndex, { models: rule.models.filter((_, i) => i !== modelIndex) });
};
const updateModel = (
ruleIndex: number,
modelIndex: number,
patch: Partial<PayloadModelEntry>
) => {
const rule = rules[ruleIndex];
updateRule(ruleIndex, {
models: rule.models.map((m, i) => (i === modelIndex ? { ...m, ...patch } : m)),
});
};
const addParam = (ruleIndex: number) => {
const rule = rules[ruleIndex];
const nextParam: PayloadParamEntry = {
id: makeClientId(),
path: '',
valueType: rawJsonValues ? 'json' : 'string',
value: '',
};
updateRule(ruleIndex, { params: [...rule.params, nextParam] });
};
const removeParam = (ruleIndex: number, paramIndex: number) => {
const rule = rules[ruleIndex];
updateRule(ruleIndex, { params: rule.params.filter((_, i) => i !== paramIndex) });
};
const updateParam = (
ruleIndex: number,
paramIndex: number,
patch: Partial<PayloadParamEntry>
) => {
const rule = rules[ruleIndex];
updateRule(ruleIndex, {
params: rule.params.map((p, i) => (i === paramIndex ? { ...p, ...patch } : p)),
});
};
const getValuePlaceholder = (valueType: PayloadParamValueType) => {
switch (valueType) {
case 'string':
return t('config_management.visual.payload_rules.value_string');
case 'number':
return t('config_management.visual.payload_rules.value_number');
case 'boolean':
return t('config_management.visual.payload_rules.value_boolean');
case 'json':
return t('config_management.visual.payload_rules.value_json');
default:
return t('config_management.visual.payload_rules.value_default');
}
};
const getParamErrorMessage = (param: PayloadParamEntry) => {
const errorCode = getPayloadParamValidationError(
rawJsonValues ? { ...param, valueType: 'json' } : param
);
return getValidationMessage(t, errorCode);
};
const renderParamValueEditor = (
ruleIndex: number,
paramIndex: number,
param: PayloadParamEntry
) => {
if (rawJsonValues) {
return (
<textarea
className={`input ${styles.payloadJsonInput}`}
placeholder={t('config_management.visual.payload_rules.value_raw_json')}
aria-label={t('config_management.visual.payload_rules.param_value')}
value={param.value}
onChange={(e) =>
updateParam(ruleIndex, paramIndex, { value: e.target.value, valueType: 'json' })
}
disabled={disabled}
/>
);
}
if (param.valueType === 'boolean') {
return (
<Select
value={
param.value.toLowerCase() === 'true' || param.value.toLowerCase() === 'false'
? param.value.toLowerCase()
: ''
}
options={booleanValueOptions}
placeholder={t('config_management.visual.payload_rules.value_boolean')}
disabled={disabled}
ariaLabel={t('config_management.visual.payload_rules.param_value')}
onChange={(nextValue) => updateParam(ruleIndex, paramIndex, { value: nextValue })}
/>
);
}
if (param.valueType === 'json') {
return (
<textarea
className={`input ${styles.payloadJsonInput}`}
placeholder={getValuePlaceholder(param.valueType)}
aria-label={t('config_management.visual.payload_rules.param_value')}
value={param.value}
onChange={(e) => updateParam(ruleIndex, paramIndex, { value: e.target.value })}
disabled={disabled}
/>
);
}
return (
<ExpandableInput
placeholder={getValuePlaceholder(param.valueType)}
ariaLabel={t('config_management.visual.payload_rules.param_value')}
value={param.value}
onChange={(nextValue) => updateParam(ruleIndex, paramIndex, { value: nextValue })}
disabled={disabled}
/>
);
};
return (
<div className={styles.blockStack}>
{rules.map((rule, ruleIndex) => (
<div key={rule.id} className={styles.ruleCard}>
<div className={styles.ruleCardHeader}>
<div className={styles.ruleCardTitle}>
{t('config_management.visual.payload_rules.rule')} {ruleIndex + 1}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => removeRule(ruleIndex)}
disabled={disabled}
>
{t('config_management.visual.common.delete')}
</Button>
</div>
<div className={styles.blockStack}>
<div className={styles.blockLabel}>
{t('config_management.visual.payload_rules.models')}
</div>
{(rule.models.length ? rule.models : []).map((model, modelIndex) => (
<div
key={model.id}
className={[
styles.payloadRuleModelRow,
protocolFirst ? styles.payloadRuleModelRowProtocolFirst : '',
]
.filter(Boolean)
.join(' ')}
>
{protocolFirst ? (
<>
<Select
value={model.protocol ?? ''}
options={protocolOptions}
disabled={disabled}
ariaLabel={t('config_management.visual.payload_rules.provider_type')}
onChange={(nextValue) =>
updateModel(ruleIndex, modelIndex, {
protocol: (nextValue || undefined) as PayloadModelEntry['protocol'],
})
}
/>
<ExpandableInput
placeholder={t('config_management.visual.payload_rules.model_name')}
ariaLabel={t('config_management.visual.payload_rules.model_name')}
value={model.name}
onChange={(nextValue) => updateModel(ruleIndex, modelIndex, { name: nextValue })}
disabled={disabled}
/>
</>
) : (
<>
<ExpandableInput
placeholder={t('config_management.visual.payload_rules.model_name')}
ariaLabel={t('config_management.visual.payload_rules.model_name')}
value={model.name}
onChange={(nextValue) => updateModel(ruleIndex, modelIndex, { name: nextValue })}
disabled={disabled}
/>
<Select
value={model.protocol ?? ''}
options={protocolOptions}
disabled={disabled}
ariaLabel={t('config_management.visual.payload_rules.provider_type')}
onChange={(nextValue) =>
updateModel(ruleIndex, modelIndex, {
protocol: (nextValue || undefined) as PayloadModelEntry['protocol'],
})
}
/>
</>
)}
<Button
variant="ghost"
size="sm"
className={styles.payloadRowActionButton}
onClick={() => removeModel(ruleIndex, modelIndex)}
disabled={disabled}
>
{t('config_management.visual.common.delete')}
</Button>
</div>
))}
<div className={styles.actionRow}>
<Button
variant="secondary"
size="sm"
onClick={() => addModel(ruleIndex)}
disabled={disabled}
>
{t('config_management.visual.payload_rules.add_model')}
</Button>
</div>
</div>
<div className={styles.blockStack}>
<div className={styles.blockLabel}>
{t('config_management.visual.payload_rules.params')}
</div>
{(rule.params.length ? rule.params : []).map((param, paramIndex) => {
const paramError = getParamErrorMessage(param);
return (
<div key={param.id} className={styles.payloadRuleParamGroup}>
<div className={styles.payloadRuleParamRow}>
<ExpandableInput
placeholder={t('config_management.visual.payload_rules.json_path')}
ariaLabel={t('config_management.visual.payload_rules.json_path')}
value={param.path}
onChange={(nextValue) => updateParam(ruleIndex, paramIndex, { path: nextValue })}
disabled={disabled}
/>
{rawJsonValues ? null : (
<Select
value={param.valueType}
options={payloadValueTypeOptions}
disabled={disabled}
ariaLabel={t('config_management.visual.payload_rules.param_type')}
onChange={(nextValue) =>
updateParam(ruleIndex, paramIndex, {
valueType: nextValue as PayloadParamValueType,
value:
nextValue === 'boolean'
? 'true'
: nextValue === 'json' && param.value.trim() === ''
? '{}'
: param.value,
})
}
/>
)}
{renderParamValueEditor(ruleIndex, paramIndex, param)}
<Button
variant="ghost"
size="sm"
className={styles.payloadRowActionButton}
onClick={() => removeParam(ruleIndex, paramIndex)}
disabled={disabled}
>
{t('config_management.visual.common.delete')}
</Button>
</div>
{paramError && (
<div className={`error-box ${styles.payloadParamError}`}>{paramError}</div>
)}
</div>
);
})}
<div className={styles.actionRow}>
<Button
variant="secondary"
size="sm"
onClick={() => addParam(ruleIndex)}
disabled={disabled}
>
{t('config_management.visual.payload_rules.add_param')}
</Button>
</div>
</div>
</div>
))}
{rules.length === 0 && (
<div className={styles.emptyState}>
{t('config_management.visual.payload_rules.no_rules')}
</div>
)}
<div className={styles.actionRow}>
<Button variant="secondary" size="sm" onClick={addRule} disabled={disabled}>
{t('config_management.visual.payload_rules.add_rule')}
</Button>
</div>
</div>
);
});
export const PayloadFilterRulesEditor = memo(function PayloadFilterRulesEditor({
value,
disabled,
onChange,
}: {
value: PayloadFilterRule[];
disabled?: boolean;
onChange: (next: PayloadFilterRule[]) => void;
}) {
const { t } = useTranslation();
const rules = value;
const protocolOptions = useMemo(() => buildProtocolOptions(t, rules), [rules, t]);
const addRule = () => onChange([...rules, { id: makeClientId(), models: [], params: [] }]);
const removeRule = (ruleIndex: number) => onChange(rules.filter((_, i) => i !== ruleIndex));
const updateRule = (ruleIndex: number, patch: Partial<PayloadFilterRule>) =>
onChange(rules.map((rule, i) => (i === ruleIndex ? { ...rule, ...patch } : rule)));
const addModel = (ruleIndex: number) => {
const rule = rules[ruleIndex];
const nextModel: PayloadModelEntry = { id: makeClientId(), name: '', protocol: undefined };
updateRule(ruleIndex, { models: [...rule.models, nextModel] });
};
const removeModel = (ruleIndex: number, modelIndex: number) => {
const rule = rules[ruleIndex];
updateRule(ruleIndex, { models: rule.models.filter((_, i) => i !== modelIndex) });
};
const updateModel = (
ruleIndex: number,
modelIndex: number,
patch: Partial<PayloadModelEntry>
) => {
const rule = rules[ruleIndex];
updateRule(ruleIndex, {
models: rule.models.map((m, i) => (i === modelIndex ? { ...m, ...patch } : m)),
});
};
return (
<div className={styles.blockStack}>
{rules.map((rule, ruleIndex) => (
<div key={rule.id} className={styles.ruleCard}>
<div className={styles.ruleCardHeader}>
<div className={styles.ruleCardTitle}>
{t('config_management.visual.payload_rules.rule')} {ruleIndex + 1}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => removeRule(ruleIndex)}
disabled={disabled}
>
{t('config_management.visual.common.delete')}
</Button>
</div>
<div className={styles.blockStack}>
<div className={styles.blockLabel}>
{t('config_management.visual.payload_rules.models')}
</div>
{rule.models.map((model, modelIndex) => (
<div key={model.id} className={styles.payloadFilterModelRow}>
<ExpandableInput
placeholder={t('config_management.visual.payload_rules.model_name')}
ariaLabel={t('config_management.visual.payload_rules.model_name')}
value={model.name}
onChange={(nextValue) => updateModel(ruleIndex, modelIndex, { name: nextValue })}
disabled={disabled}
/>
<Select
value={model.protocol ?? ''}
options={protocolOptions}
disabled={disabled}
ariaLabel={t('config_management.visual.payload_rules.provider_type')}
onChange={(nextValue) =>
updateModel(ruleIndex, modelIndex, {
protocol: (nextValue || undefined) as PayloadModelEntry['protocol'],
})
}
/>
<Button
variant="ghost"
size="sm"
className={styles.payloadRowActionButton}
onClick={() => removeModel(ruleIndex, modelIndex)}
disabled={disabled}
>
{t('config_management.visual.common.delete')}
</Button>
</div>
))}
<div className={styles.actionRow}>
<Button
variant="secondary"
size="sm"
onClick={() => addModel(ruleIndex)}
disabled={disabled}
>
{t('config_management.visual.payload_rules.add_model')}
</Button>
</div>
</div>
<div className={styles.blockStack}>
<div className={styles.blockLabel}>
{t('config_management.visual.payload_rules.remove_params')}
</div>
<StringListEditor
value={rule.params}
disabled={disabled}
placeholder={t('config_management.visual.payload_rules.json_path_filter')}
inputAriaLabel={t('config_management.visual.payload_rules.json_path_filter')}
onChange={(params) => updateRule(ruleIndex, { params })}
/>
</div>
</div>
))}
{rules.length === 0 && (
<div className={styles.emptyState}>
{t('config_management.visual.payload_rules.no_rules')}
</div>
)}
<div className={styles.actionRow}>
<Button variant="secondary" size="sm" onClick={addRule} disabled={disabled}>
{t('config_management.visual.payload_rules.add_rule')}
</Button>
</div>
</div>
);
});