diff --git a/src/components/configuration/FieldProfilePopover.tsx b/src/components/configuration/FieldProfilePopover.tsx index f495d964..bc59b730 100644 --- a/src/components/configuration/FieldProfilePopover.tsx +++ b/src/components/configuration/FieldProfilePopover.tsx @@ -5,11 +5,11 @@ import type * as t from '@/types'; import { ProfileValueModal, getDefaultValue } from './ProfileValueModal'; import { DeleteProfileValueModal } from './DeleteProfileValueModal'; import { EditButton, TrashButton } from '@/components/shared'; +import { getControlType, serializeModalValue } from './utils'; import { useProfileMutations, useLocalize } from '@/hooks'; import { availableScopesOptions } from '@/server'; import { getScopeTypeConfig } from '@/constants'; -import { serializeKVPairs, cn } from '@/utils'; -import { getControlType } from './utils'; +import { cn } from '@/utils'; export function FieldProfilePopover({ fieldPath, @@ -71,7 +71,7 @@ export function FieldProfilePopover({ const handleModalSave = useCallback(() => { if (modalIsBase && onBaseValueChange) { - onBaseValueChange(serializeKVPairs(modalValue)); + onBaseValueChange(serializeModalValue(controlType, modalValue)); setModalOpen(false); setModalIsBase(false); return; @@ -81,7 +81,7 @@ export function FieldProfilePopover({ { principalType: modalScope.principalType, principalId: modalScope.principalId, - value: serializeKVPairs(modalValue), + value: serializeModalValue(controlType, modalValue), }, { onSuccess: () => { @@ -94,7 +94,7 @@ export function FieldProfilePopover({ }, }, ); - }, [modalIsBase, modalScope, modalValue, modalMode, saveMutation, onBaseValueChange]); + }, [modalIsBase, modalScope, modalValue, modalMode, controlType, saveMutation, onBaseValueChange]); const handleModalCancel = useCallback(() => { setModalOpen(false); 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) => ( +