diff --git a/src/components/configuration/ConfigPage.tsx b/src/components/configuration/ConfigPage.tsx index 6d30330d..f39447ee 100644 --- a/src/components/configuration/ConfigPage.tsx +++ b/src/components/configuration/ConfigPage.tsx @@ -10,6 +10,7 @@ import { tombstoneFieldProfileValueFn, bulkSaveProfileValuesFn, getBatchFieldProfilesFn, + getBaseConfigOverridesFn, availableScopesOptions, resetBaseConfigFieldFn, getResolvedConfigFn, @@ -38,12 +39,15 @@ import { buildSavePayload, mergeIndexedArrayEdits, partitionScopeResetPaths, + collectEntryOverrideKeys, + executeEntryOverridesReset, } from './utils'; import { validateMcpCrossField } from './sections/McpServersRenderer'; import { ScopeSelector, ScopeTriggerButton } from './ScopeSelector'; import { StickyActionBar } from '@/components/shared'; import { ConfigTableOfContents } from './ConfigTableOfContents'; import { ResetBaseConfigDialog } from './ResetBaseConfigDialog'; +import { ResetOverridesDialog } from './ResetOverridesDialog'; import { ConfirmSaveDialog } from './ConfirmSaveDialog'; import { ConfigTabContent } from './ConfigTabContent'; import { ImportYamlDialog } from './ImportYamlDialog'; @@ -152,9 +156,15 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi if (yamlMcpKeys && Array.isArray(yamlMcpKeys)) { result.mcpServers = new Set(yamlMcpKeys); } + const yamlCustomEndpointKeys = baseConfigData?.yamlCustomEndpointKeys; + if (yamlCustomEndpointKeys && Array.isArray(yamlCustomEndpointKeys)) { + result.endpoints = new Set(yamlCustomEndpointKeys); + } return result; }, [baseConfigData]); + const dbOverrideKeys = useMemo(() => collectEntryOverrideKeys(dbOverrides), [dbOverrides]); + const hasUnmappedSections = useMemo( () => schemaTree.some( @@ -536,6 +546,39 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi }); }, []); + const [entryResetTarget, setEntryResetTarget] = useState(null); + const [entryResetting, setEntryResetting] = useState(false); + const [entryResetError, setEntryResetError] = useState(null); + + const handleEntryOverridesReset = useCallback((target: t.EntryResetTarget) => { + setEntryResetError(null); + setEntryResetTarget(target); + }, []); + + const handleEntryResetConfirm = useCallback(async () => { + if (!entryResetTarget || entryResetting) return; + setEntryResetting(true); + setEntryResetError(null); + try { + await executeEntryOverridesReset(entryResetTarget, schemaPathSet, { + fetchOverrides: () => getBaseConfigOverridesFn().then((r) => r.overrides), + resetField: (fieldPath) => resetBaseConfigFieldFn({ data: { fieldPath } }), + saveEntries: (entries) => saveBaseConfigFn({ data: { entries } }), + }); + await queryClient.invalidateQueries({ queryKey: ['baseConfig'] }); + /** Remount session-keyed field state (e.g. a SecretField opened for Replace but left untouched, which no invalidation reaches) so nothing local survives past the reset. */ + setEditSessionId((id) => id + 1); + setEntryResetting(false); + setEntryResetTarget(null); + notifySuccess(localize('com_config_reset_entry_success', { name: entryResetTarget.label })); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setEntryResetting(false); + setEntryResetError(message); + notifyError(message); + } + }, [entryResetTarget, entryResetting, schemaPathSet, queryClient, localize]); + const handleConfirmSave = useCallback(async () => { if (saving) return; const { touched, saves, resets } = buildSavePayload(touchedPaths, editedValues, schemaPathSet); @@ -1006,6 +1049,8 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi showConfiguredOnly={showConfiguredOnly} isEditingScope={isEditingScope} baseRecordKeys={baseRecordKeys} + dbOverrideKeys={isEditingScope ? undefined : dbOverrideKeys} + onResetEntryOverrides={isEditingScope ? undefined : handleEntryOverridesReset} onValidationError={(message) => notifyError(message)} editSessionId={editSessionId} /> @@ -1068,6 +1113,18 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi setResetBaseError(null); }} /> + + { + if (entryResetting) return; + setEntryResetTarget(null); + setEntryResetError(null); + }} + /> ); } diff --git a/src/components/configuration/ConfigTabContent.tsx b/src/components/configuration/ConfigTabContent.tsx index fa0fcd7e..13940af7 100644 --- a/src/components/configuration/ConfigTabContent.tsx +++ b/src/components/configuration/ConfigTabContent.tsx @@ -57,6 +57,8 @@ export function ConfigTabContent({ showConfiguredOnly, isEditingScope, baseRecordKeys, + dbOverrideKeys, + onResetEntryOverrides, onValidationError, editSessionId, }: t.ConfigTabContentProps) { @@ -188,6 +190,8 @@ export function ConfigTabContent({ showConfiguredOnly, isEditingScope, yamlBaseKeys: baseRecordKeys?.[dataKey], + dbOverrideKeys: dbOverrideKeys?.[dataKey], + onResetEntryOverrides, onValidationError, editSessionId, }; diff --git a/src/components/configuration/ResetOverridesDialog.tsx b/src/components/configuration/ResetOverridesDialog.tsx new file mode 100644 index 00000000..7198cad9 --- /dev/null +++ b/src/components/configuration/ResetOverridesDialog.tsx @@ -0,0 +1,62 @@ +import { Button, Dialog } from '@clickhouse/click-ui'; +import type * as t from '@/types'; +import { useLocalize } from '@/hooks'; + +export function ResetOverridesDialog({ + target, + resetting, + error, + onConfirm, + onCancel, +}: t.ResetOverridesDialogProps) { + const localize = useLocalize(); + + return ( + { + if (!isOpen) onCancel(); + }} + > + +
+

+ {localize('com_config_reset_entry_confirm', { name: target?.label ?? '' })} +

+ {error && ( +
+ {error} +
+ )} +
+
+
+
+
+ ); +} diff --git a/src/components/configuration/fields/ArrayObjectField.tsx b/src/components/configuration/fields/ArrayObjectField.tsx index 52b807cb..fd3cb11b 100644 --- a/src/components/configuration/fields/ArrayObjectField.tsx +++ b/src/components/configuration/fields/ArrayObjectField.tsx @@ -25,6 +25,7 @@ export function ArrayObjectField({ addTriggerRef, renderFields, entryIdPrefix, + entryControls, editSessionId, }: t.ArrayObjectFieldProps) { const localize = useLocalize(); @@ -104,21 +105,29 @@ export function ArrayObjectField({ onClick={handleAdd} /> )} - {items.map((item, index) => ( - handleEntryChange(index, v)} - onRemove={disabled ? undefined : () => handleRemove(index)} - disabled={disabled} - defaultExpanded={keys[index] === expandedKeyRef.current} - renderFields={renderFields} - editSessionId={editSessionId} - /> - ))} + {items.map((item, index) => { + const controls = entryControls?.(index, item); + return ( + handleEntryChange(index, v)} + onRemove={ + disabled || controls?.canRemove === false ? undefined : () => handleRemove(index) + } + resetOverrides={controls?.resetOverrides} + disabled={disabled} + defaultExpanded={keys[index] === expandedKeyRef.current} + renderFields={renderFields} + editSessionId={editSessionId} + /> + ); + })} {items.length === 0 && !hideAddButton && (

{localize('com_config_no_entries')} diff --git a/src/components/configuration/fields/ObjectEntryCard.tsx b/src/components/configuration/fields/ObjectEntryCard.tsx index 4e4a3121..aae99154 100644 --- a/src/components/configuration/fields/ObjectEntryCard.tsx +++ b/src/components/configuration/fields/ObjectEntryCard.tsx @@ -1,4 +1,4 @@ -import { Icon } from '@clickhouse/click-ui'; +import { Icon, IconButton } from '@clickhouse/click-ui'; import { useState, useCallback, useRef, useEffect } from 'react'; import type * as t from '@/types'; import { TrashButton } from '@/components/shared'; @@ -14,6 +14,7 @@ export function ObjectEntryCard({ onValueChange, onRemove, onRename, + resetOverrides, disabled, defaultExpanded = false, renderFields, @@ -191,6 +192,24 @@ export function ObjectEntryCard({ {localize('com_config_add_field')} )} + {!disabled && resetOverrides && ( + e.stopPropagation()} + title={resetOverrides.title} + > + { + e.preventDefault(); + resetOverrides.onClick(); + }} + disabled={resetOverrides.disabled} + aria-label={localize('com_a11y_reset_entry_overrides', { name: entryKey })} + /> + + )} {!disabled && onRemove && ( = { */ const REQUIRED_ENDPOINT_KEYS = new Set(['name', 'apiKey', 'baseURL']); +/** + * LibreChat merges the `endpoints.custom` override array into the YAML array + * by item `name`, so `name` is the entry's identity across config layers. + * Renaming a YAML-defined endpoint can never work: the renamed override item + * no longer matches its YAML counterpart, which survives unchanged while the + * override is appended as a duplicate entry. + */ +const YAML_LOCKED_ENDPOINT_FIELDS = new Set(['name']); + function withRequired(field: t.SchemaField): t.SchemaField { if (REQUIRED_ENDPOINT_KEYS.has(field.key)) { return { ...field, isOptional: false }; @@ -129,6 +138,7 @@ function flattenGroupFields( disabled?: boolean, collectionRenderOverrides?: Record, editSessionId?: number, + lockedKeys?: Set, ): ReactNode[] { const values = typeof parentValue === 'object' && parentValue !== null && !Array.isArray(parentValue) @@ -137,6 +147,7 @@ function flattenGroupFields( const nodes: ReactNode[] = []; for (const field of fields) { + const fieldDisabled = disabled || (lockedKeys?.has(field.key) ?? false); if (field.children && field.children.length > 0 && !field.isArray && field.type !== 'record') { const nested = values[field.key]; const nestedObj = @@ -153,7 +164,7 @@ function flattenGroupFields( onChange(field.key, { ...nestedObj, [childKey]: childValue }); }, localize, - disabled, + fieldDisabled, collectionRenderOverrides, true, editSessionId, @@ -168,7 +179,7 @@ function flattenGroupFields( parentPath, onChange, localize, - disabled, + fieldDisabled, collectionRenderOverrides, true, editSessionId, @@ -189,6 +200,7 @@ function FieldGroup({ defaultExpanded, collectionRenderOverrides, editSessionId, + lockedKeys, }: { labelKey: string; fields: t.SchemaField[]; @@ -199,6 +211,7 @@ function FieldGroup({ defaultExpanded: boolean; collectionRenderOverrides?: Record; editSessionId?: number; + lockedKeys?: Set; }) { const localize = useLocalize(); const { isExpanded, hasEverExpanded, sectionRef, toggle } = useCollapsibleSection({ @@ -242,6 +255,7 @@ function FieldGroup({ disabled, collectionRenderOverrides, editSessionId, + lockedKeys, )} , )} @@ -258,6 +272,7 @@ function GroupedFieldRenderer({ disabled, collectionRenderOverrides, editSessionId, + lockedKeys, }: { groupKey: string; fields: t.SchemaField[]; @@ -267,6 +282,7 @@ function GroupedFieldRenderer({ disabled?: boolean; collectionRenderOverrides?: Record; editSessionId?: number; + lockedKeys?: Set; }) { const groups = FIELD_GROUPS[groupKey]; if (!groups) return null; @@ -293,6 +309,7 @@ function GroupedFieldRenderer({ defaultExpanded={group.defaultExpanded} collectionRenderOverrides={collectionRenderOverrides} editSessionId={editSessionId} + lockedKeys={lockedKeys} /> ); })} @@ -307,6 +324,7 @@ function GroupedFieldRenderer({ defaultExpanded={false} collectionRenderOverrides={collectionRenderOverrides} editSessionId={editSessionId} + lockedKeys={lockedKeys} /> )} @@ -466,7 +484,10 @@ const COLLECTION_RENDER_OVERRIDES: Record = { * Matches the `CollectionRenderFields` signature so it can be injected * into `ArrayObjectField` and `ObjectEntryCard`. */ -function makeGroupedEndpointFields(disabled?: boolean): t.CollectionRenderFields { +function makeGroupedEndpointFields( + disabled?: boolean, + isEntryNameLocked?: (entry: t.ConfigValue) => boolean, +): t.CollectionRenderFields { return (fields, parentValue, parentPath, onChange, _addFieldTriggerRef, editSessionId) => ( ); } @@ -611,12 +633,86 @@ function ProviderSection({ // --------------------------------------------------------------------------- export function CustomEndpointsRenderer(props: t.FieldRendererProps) { - const { fields, parentPath, parentValue, getValue, onChange, disabled, editSessionId } = props; + const { + fields, + parentPath, + parentValue, + getValue, + onChange, + disabled, + editedValues, + yamlBaseKeys, + dbOverrideKeys, + isEditingScope, + onResetEntryOverrides, + editSessionId, + } = props; const localize = useLocalize(); const [createOpen, setCreateOpen] = useState(false); + + /** `yamlBaseKeys` is undefined only when the YAML provenance (baseOnly) fetch failed. Fail closed: with unknown provenance any entry could be YAML-defined, and a staged delete or rename would both fail (the name-based merge restores the YAML entry) and freeze the whole array into the override document. */ + const provenanceUnknown = !isEditingScope && yamlBaseKeys === undefined; + + /** YAML identity locks apply in base mode only; scope overrides layer on top of the resolved base and keep their own affordances. */ + const yamlNames = useMemo( + () => (isEditingScope ? new Set() : (yamlBaseKeys ?? new Set())), + [isEditingScope, yamlBaseKeys], + ); + + const isEntryNameLocked = useCallback( + (entry: t.ConfigValue) => { + if (provenanceUnknown) return true; + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return false; + const name = (entry as Record).name; + return typeof name === 'string' && yamlNames.has(name); + }, + [provenanceUnknown, yamlNames], + ); + const renderGroupedEndpointFields = useMemo( - () => makeGroupedEndpointFields(disabled), - [disabled], + () => makeGroupedEndpointFields(disabled, isEntryNameLocked), + [disabled, isEntryNameLocked], + ); + + const renderCreateEndpointFields = useMemo(() => makeGroupedEndpointFields(disabled), [disabled]); + + const hasPendingEdits = !!editedValues && Object.keys(editedValues).length > 0; + + const entryControls = useCallback( + (_index: number, item: t.ConfigValue): t.EntryCardControls => { + if (provenanceUnknown) return { canRemove: false }; + const obj = + item && typeof item === 'object' && !Array.isArray(item) + ? (item as Record) + : {}; + const name = typeof obj.name === 'string' ? obj.name : ''; + const isYamlSource = name !== '' && yamlNames.has(name); + if (!isYamlSource) return {}; + /** YAML-defined endpoints cannot be deleted from the admin panel: the merge preserves unmatched YAML items, so the entry would reappear on the next load. Offer clearing the override layer instead. */ + const resetOverrides: t.EntryResetAction | undefined = + onResetEntryOverrides && dbOverrideKeys?.has(name) + ? { + onClick: () => + onResetEntryOverrides({ + fieldPath: `${parentPath}.custom`, + itemName: name, + label: name, + }), + disabled: hasPendingEdits, + title: hasPendingEdits ? localize('com_config_reset_base_dirty') : undefined, + } + : undefined; + return { canRemove: false, resetOverrides }; + }, + [ + provenanceUnknown, + yamlNames, + dbOverrideKeys, + onResetEntryOverrides, + parentPath, + hasPendingEdits, + localize, + ], ); const customField = fields.find((f) => f.key === 'custom'); @@ -661,6 +757,7 @@ export function CustomEndpointsRenderer(props: t.FieldRendererProps) { hideAddButton renderFields={renderGroupedEndpointFields} entryIdPrefix={`section-${path.split('.')[0]}-custom`} + entryControls={entryControls} editSessionId={editSessionId} /> )} @@ -669,7 +766,7 @@ export function CustomEndpointsRenderer(props: t.FieldRendererProps) { onClose={() => setCreateOpen(false)} onSave={handleCreate} fields={customField.children ?? []} - renderFields={renderGroupedEndpointFields} + renderFields={renderCreateEndpointFields} /> ); diff --git a/src/components/configuration/sections/McpServersRenderer.tsx b/src/components/configuration/sections/McpServersRenderer.tsx index 5eb0f985..659e20eb 100644 --- a/src/components/configuration/sections/McpServersRenderer.tsx +++ b/src/components/configuration/sections/McpServersRenderer.tsx @@ -43,6 +43,9 @@ const TRANSPORT_TYPE_OPTIONS: { label: string; value: string }[] = [ const ALWAYS_REQUIRED = new Set(['type']); +/** Segments `safeFieldPath` rejects server-side; a server key matching one can never round-trip through the field-path API. */ +const UNADDRESSABLE_SEGMENTS = new Set(['__proto__', 'constructor', 'prototype']); + /** Stable empty record used as the fallback for `baseRecord`/`parentValue` when no data is available, so the downstream `useMemo` chain on `editsByEntry`/`record` does not re-fire on every render with a fresh `{}` literal. */ const EMPTY_RECORD: Record = Object.freeze({}) as Record< string, @@ -212,6 +215,7 @@ function flattenGroupFields( disabled?: boolean, collectionRenderOverrides?: Record, lockedKeys?: Set, + editSessionId?: number, ): ReactNode[] { const values = isPlainObject(parentValue) ? parentValue : {}; @@ -264,6 +268,7 @@ function flattenGroupFields( fieldDisabled, collectionRenderOverrides, true, + editSessionId, ), ); } @@ -278,6 +283,7 @@ function flattenGroupFields( fieldDisabled, collectionRenderOverrides, true, + editSessionId, ), ); } @@ -340,6 +346,7 @@ function FieldGroup({ defaultExpanded, transportType, lockedKeys, + editSessionId, }: { labelKey: string; fields: t.SchemaField[]; @@ -350,6 +357,7 @@ function FieldGroup({ defaultExpanded: boolean; transportType: string; lockedKeys?: Set; + editSessionId?: number; }) { const localize = useLocalize(); const { isExpanded, hasEverExpanded, sectionRef, toggle } = useCollapsibleSection({ @@ -394,6 +402,7 @@ function FieldGroup({ disabled, undefined, lockedKeys, + editSessionId, )} , )} @@ -408,6 +417,7 @@ function McpEntryFields({ onChange, disabled, lockedKeys, + editSessionId, }: { fields: t.SchemaField[]; parentValue: t.ConfigValue; @@ -415,6 +425,7 @@ function McpEntryFields({ onChange: (path: string, value: t.ConfigValue) => void; disabled?: boolean; lockedKeys?: Set; + editSessionId?: number; }) { const localize = useLocalize(); const values = isPlainObject(parentValue) ? parentValue : {}; @@ -479,6 +490,7 @@ function McpEntryFields({ disabled, undefined, lockedKeys, + editSessionId, )} )} @@ -494,6 +506,7 @@ function McpEntryFields({ defaultExpanded={child.defaultExpanded} transportType={currentType} lockedKeys={lockedKeys} + editSessionId={editSessionId} /> ))} @@ -512,6 +525,7 @@ function McpEntryFields({ defaultExpanded={group.defaultExpanded} transportType={currentType} lockedKeys={lockedKeys} + editSessionId={editSessionId} /> ); }; @@ -530,6 +544,7 @@ function McpEntryFields({ defaultExpanded={false} transportType={currentType} lockedKeys={lockedKeys} + editSessionId={editSessionId} /> )} @@ -662,8 +677,11 @@ export function McpServersRenderer(props: t.FieldRendererProps) { disabled, editedValues, yamlBaseKeys, + dbOverrideKeys, isEditingScope, + onResetEntryOverrides, onValidationError, + editSessionId, } = props; const localize = useLocalize(); const [createOpen, setCreateOpen] = useState(false); @@ -786,6 +804,21 @@ export function McpServersRenderer(props: t.FieldRendererProps) { localizeRef.current = localize; }, [localize]); + const onResetEntryOverridesRef = useRef(onResetEntryOverrides); + useEffect(() => { + onResetEntryOverridesRef.current = onResetEntryOverrides; + }, [onResetEntryOverrides]); + + const handleResetOverrides = useCallback( + (key: string) => { + onResetEntryOverridesRef.current?.({ fieldPath: `${path}.${key}`, label: key }); + }, + [path], + ); + + /** Immediate reset would interleave with staged edits, so it stays locked until they are saved or discarded (same rule as the global reset button). */ + const hasPendingEdits = !!editedValues && Object.keys(editedValues).length > 0; + const handleCreate = useCallback( (serverName: string, entry: Record) => { if (serverName.includes('.')) { @@ -942,10 +975,16 @@ export function McpServersRenderer(props: t.FieldRendererProps) { disabled={disabled} isEditingScope={!!isEditingScope} isYamlSource={yamlSourceKeys.has(key)} + canResetOverrides={ + !isEditingScope && yamlSourceKeys.has(key) && (dbOverrideKeys?.has(key) ?? false) + } + resetDisabled={hasPendingEdits} onChange={onChange} onRemove={handleRemove} onRename={handleRename} + onResetOverrides={onResetEntryOverrides ? handleResetOverrides : undefined} justAdded={key === justAddedKey} + editSessionId={editSessionId} /> ))} {!disabled && entries.length === 0 && ( @@ -981,10 +1020,14 @@ const McpEntryRow = memo(function McpEntryRowImpl({ disabled, isEditingScope, isYamlSource, + canResetOverrides, + resetDisabled, onChange, onRemove, onRename, + onResetOverrides, justAdded, + editSessionId, }: { entryKey: string; entryValue: t.ConfigValue; @@ -993,11 +1036,16 @@ const McpEntryRow = memo(function McpEntryRowImpl({ disabled?: boolean; isEditingScope: boolean; isYamlSource: boolean; + canResetOverrides: boolean; + resetDisabled: boolean; onChange: (path: string, value: t.ConfigValue) => void; onRemove: (key: string) => void; onRename: (oldKey: string, newKey: string) => void; + onResetOverrides?: (key: string) => void; justAdded: boolean; + editSessionId?: number; }) { + const localize = useLocalize(); const entryObj = isPlainObject(entryValue) ? entryValue : {}; const rawType = typeof entryObj.type === 'string' ? entryObj.type : ''; const inferred = rawType || inferTransportType(entryObj); @@ -1006,18 +1054,18 @@ const McpEntryRow = memo(function McpEntryRowImpl({ effectiveType !== rawType ? { ...entryObj, type: effectiveType } : entryValue; const entryPathBase = `${path}.${entryKey}`; - /** Dotted entry names predate the dot-rejecting create/rename validators; the save endpoint parses fieldPath as dot-delimited so any per-leaf write under such a key collides with a parallel "legacy" → "dotted" nested-object interpretation. Render them read-only so they stay visible in the list but never round-trip through the per-field save API. */ - const isDottedLegacy = entryKey.includes('.'); - const isReadOnly = !!disabled || isDottedLegacy; - const isLockedIdentity = (!isEditingScope && isYamlSource) || isDottedLegacy; - const lockedKeys = isYamlSource && !isDottedLegacy ? YAML_LOCKED_FIELDS : undefined; + /** Dotted entry names predate the dot-rejecting create/rename validators; the save endpoint parses fieldPath as dot-delimited so any per-leaf write under such a key collides with a parallel "legacy" → "dotted" nested-object interpretation. Keys matching `safeFieldPath`'s rejected segments are equally unaddressable: every field-path write or unset for them fails server-side validation. Render both read-only so they stay visible in the list but never round-trip through the per-field save API. */ + const isUnaddressableKey = entryKey.includes('.') || UNADDRESSABLE_SEGMENTS.has(entryKey); + const isReadOnly = !!disabled || isUnaddressableKey; + const isLockedIdentity = (!isEditingScope && isYamlSource) || isUnaddressableKey; + const lockedKeys = isYamlSource && !isUnaddressableKey ? YAML_LOCKED_FIELDS : undefined; const entryOnChange = useCallback( (leafKey: string, leafValue: t.ConfigValue) => { - if (isDottedLegacy) return; + if (isUnaddressableKey) return; onChange(`${entryPathBase}.${leafKey}`, leafValue); }, - [onChange, entryPathBase, isDottedLegacy], + [onChange, entryPathBase, isUnaddressableKey], ); const renderEntryFields: t.CollectionRenderFields = useCallback( @@ -1029,20 +1077,30 @@ const McpEntryRow = memo(function McpEntryRowImpl({ onChange={entryOnChange} disabled={isReadOnly} lockedKeys={lockedKeys} + editSessionId={editSessionId} /> ), - [entryOnChange, isReadOnly, lockedKeys], + [entryOnChange, isReadOnly, lockedKeys, editSessionId], ); /** Required by ObjectEntryCard's onValueChange contract; unused on leaf edits. */ const handleWholeEntryChange = useCallback( (v: t.ConfigValue) => { - if (isDottedLegacy) return; + if (isUnaddressableKey) return; onChange(entryPathBase, v); }, - [onChange, entryPathBase, isDottedLegacy], + [onChange, entryPathBase, isUnaddressableKey], ); + const resetOverrides: t.EntryResetAction | undefined = + !isReadOnly && canResetOverrides && onResetOverrides + ? { + onClick: () => onResetOverrides(entryKey), + disabled: resetDisabled, + title: resetDisabled ? localize('com_config_reset_base_dirty') : undefined, + } + : undefined; + return ( onRename(entryKey, renamed) } + resetOverrides={resetOverrides} disabled={isReadOnly} defaultExpanded={justAdded} renderFields={renderEntryFields} diff --git a/src/components/configuration/sections/__tests__/EndpointsRenderer.test.tsx b/src/components/configuration/sections/__tests__/EndpointsRenderer.test.tsx index 76d63adf..544f95e3 100644 --- a/src/components/configuration/sections/__tests__/EndpointsRenderer.test.tsx +++ b/src/components/configuration/sections/__tests__/EndpointsRenderer.test.tsx @@ -1,7 +1,7 @@ import { describe, it, expect, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { render, screen, fireEvent } from '@testing-library/react'; import type * as t from '@/types'; -import { ProvidersRenderer } from '../EndpointsRenderer'; +import { ProvidersRenderer, CustomEndpointsRenderer } from '../EndpointsRenderer'; import { createField } from '@/test/fixtures'; vi.mock('@/hooks/useLocalize', () => ({ @@ -17,6 +17,13 @@ interface MockTextFieldProps { 'aria-label'?: string; } +interface MockIconButtonProps { + icon: string; + onClick?: () => void; + disabled?: boolean; + 'aria-label'?: string; +} + vi.mock('@clickhouse/click-ui', () => ({ Icon: () => , MultiAccordion: Object.assign( @@ -25,6 +32,14 @@ vi.mock('@clickhouse/click-ui', () => ({ Item: ({ children }: { children: React.ReactNode }) =>

{children}
, }, ), + IconButton: ({ icon, onClick, disabled, ...rest }: MockIconButtonProps) => ( +