From b522a1184c2d8b1883deb8b917f2a1b5222c6495 Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Sun, 9 Aug 2026 17:36:16 -0700 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=90=9B=20fix:=20Keep=20KV=20Record=20?= =?UTF-8?q?Edits=20Object-Typed=20so=20Headers=20Never=20Save=20as=20Array?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Emptying a KeyValueField (removing the last header, env, or addParams row) stored a bare [] in edit state. serializeKVPairs cannot recognize an empty array as KV pairs, so the [] reached the backend as an array where the schema expects a record, failing base-config validation with "Expected object, received array" and persisting invalid values on the unvalidated profile-value route. Record-control edits now collapse an emptied pair list to {} via a shared kvPairsEditValue helper (FieldRenderer record branches and ProfileValueModal), getDefaultValue returns {} for record fields so an untouched profile-value save stays object-typed, and FieldProfilePopover serializes modal values with deepSerializeKVPairs so no nested pairs shape can leak through the one save route that bypasses buildSavePayload. Adds regression tests proving MCP header edits serialize to a plain record in the save payload (issue #56 repro) and that emptied KV lists emit {} rather than []. --- .../configuration/FieldProfilePopover.tsx | 6 +- .../configuration/FieldRenderer.test.tsx | 16 ++++ .../configuration/FieldRenderer.tsx | 7 +- .../configuration/ProfileValueModal.test.tsx | 85 +++++++++++++++++++ .../configuration/ProfileValueModal.tsx | 6 +- .../__tests__/McpServersRenderer.test.tsx | 58 +++++++++++++ src/components/configuration/utils.test.ts | 48 +++++++++++ src/components/configuration/utils.ts | 11 +++ 8 files changed, 228 insertions(+), 9 deletions(-) create mode 100644 src/components/configuration/ProfileValueModal.test.tsx diff --git a/src/components/configuration/FieldProfilePopover.tsx b/src/components/configuration/FieldProfilePopover.tsx index f495d964..7f2e589e 100644 --- a/src/components/configuration/FieldProfilePopover.tsx +++ b/src/components/configuration/FieldProfilePopover.tsx @@ -6,9 +6,9 @@ import { ProfileValueModal, getDefaultValue } from './ProfileValueModal'; import { DeleteProfileValueModal } from './DeleteProfileValueModal'; import { EditButton, TrashButton } from '@/components/shared'; import { useProfileMutations, useLocalize } from '@/hooks'; +import { deepSerializeKVPairs, cn } from '@/utils'; import { availableScopesOptions } from '@/server'; import { getScopeTypeConfig } from '@/constants'; -import { serializeKVPairs, cn } from '@/utils'; import { getControlType } from './utils'; export function FieldProfilePopover({ @@ -71,7 +71,7 @@ export function FieldProfilePopover({ const handleModalSave = useCallback(() => { if (modalIsBase && onBaseValueChange) { - onBaseValueChange(serializeKVPairs(modalValue)); + onBaseValueChange(deepSerializeKVPairs(modalValue)); setModalOpen(false); setModalIsBase(false); return; @@ -81,7 +81,7 @@ export function FieldProfilePopover({ { principalType: modalScope.principalType, principalId: modalScope.principalId, - value: serializeKVPairs(modalValue), + value: deepSerializeKVPairs(modalValue), }, { onSuccess: () => { diff --git a/src/components/configuration/FieldRenderer.test.tsx b/src/components/configuration/FieldRenderer.test.tsx index 8a23a885..c04123ba 100644 --- a/src/components/configuration/FieldRenderer.test.tsx +++ b/src/components/configuration/FieldRenderer.test.tsx @@ -215,6 +215,22 @@ describe('SingleFieldRenderer', () => { expect(screen.getByDisplayValue('Authorization')).toBeInTheDocument(); expect(screen.getByDisplayValue('Bearer token')).toBeInTheDocument(); }); + + it('emits an empty record, not an empty array, when the last key-value row is removed', () => { + const field = createField({ key: 'headers', type: 'record' }); + const onChange = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole('button', { name: 'com_ui_delete com_ui_entry 1' })); + expect(onChange).toHaveBeenCalledWith('section.headers', {}); + }); }); describe('FieldRenderer with imported config values', () => { diff --git a/src/components/configuration/FieldRenderer.tsx b/src/components/configuration/FieldRenderer.tsx index bc1e6d0b..ac07de07 100644 --- a/src/components/configuration/FieldRenderer.tsx +++ b/src/components/configuration/FieldRenderer.tsx @@ -10,6 +10,7 @@ import { getControlType, getEnumOptions, hasDescendant, + kvPairsEditValue, toKVPair, isStringLikeItemType, splitUnionTypes, @@ -23,6 +24,7 @@ import { ListRecordField } from './fields/ListRecordField'; import { renderCollapsible } from './renderCollapsible'; import { TextareaField } from './fields/TextareaField'; import { KeyValueField } from './fields/KeyValueField'; +import { cn, getSecretPreviewValue } from '@/utils'; import { NumberField } from './fields/NumberField'; import { SecretField } from './fields/SecretField'; import { ToggleField } from './fields/ToggleField'; @@ -32,7 +34,6 @@ import { ListField } from './fields/ListField'; import { CodeField } from './fields/CodeField'; import { ConfigRow } from './ConfigRow'; import { useLocalize } from '@/hooks'; -import { cn, getSecretPreviewValue } from '@/utils'; function formatDefault(value: t.ConfigValue): string | null { if (value === undefined || value === null) return null; @@ -477,7 +478,7 @@ export function SingleFieldRenderer({ onChange(path, newPairs)} + onChange={(newPairs) => onChange(path, kvPairsEditValue(newPairs))} disabled={disabled} valueTypes={field.recordValueKVTypes} aria-label={fieldLabel} @@ -1221,7 +1222,7 @@ export function renderInlineField( onChange(field.key, p)} + onChange={(p) => onChange(field.key, kvPairsEditValue(p))} disabled={disabled} valueTypes={field.recordValueKVTypes} aria-label={fieldLabel} diff --git a/src/components/configuration/ProfileValueModal.test.tsx b/src/components/configuration/ProfileValueModal.test.tsx new file mode 100644 index 00000000..8d6a7685 --- /dev/null +++ b/src/components/configuration/ProfileValueModal.test.tsx @@ -0,0 +1,85 @@ +import { vi, describe, it, expect } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import type * as t from '@/types'; +import { ProfileValueModal, getDefaultValue } from './ProfileValueModal'; +import { createField } from '@/test/fixtures'; + +vi.mock('@/hooks/useLocalize', () => ({ + default: () => (key: string) => key, + useLocalize: () => (key: string) => key, +})); + +interface ChildrenProps { + children?: React.ReactNode; +} +interface ButtonProps { + label?: string; + onClick?: () => void; +} +interface IconButtonProps { + icon: string; + onClick?: () => void; + 'aria-label'?: string; +} + +vi.mock('@clickhouse/click-ui', () => ({ + Icon: () => null, + Button: ({ label, onClick }: ButtonProps) => , + IconButton: ({ icon, onClick, ...props }: IconButtonProps) => ( +