diff --git a/src/components/configuration/ImportYamlDialog.tsx b/src/components/configuration/ImportYamlDialog.tsx index 0cdd5ae9..41f81e22 100644 --- a/src/components/configuration/ImportYamlDialog.tsx +++ b/src/components/configuration/ImportYamlDialog.tsx @@ -5,6 +5,7 @@ import { Icon, Button, Dialog, Tabs } from '@clickhouse/click-ui'; import type * as t from '@/types'; import { availableScopesOptions, createGroupFn, createRoleFn, parseImportedYaml } from '@/server'; import { getScopeTypeConfig } from '@/constants'; +import { InfoBanner } from './InfoBanner'; import { useLocalize } from '@/hooks'; import { cn } from '@/utils'; @@ -24,6 +25,7 @@ export function ImportYamlDialog({ const [loading, setLoading] = useState(false); const [error, setError] = useState(); const [validationErrors, setValidationErrors] = useState(); + const [preservedValues, setPreservedValues] = useState([]); const [step, setStep] = useState('input'); const [parsedConfig, setParsedConfig] = useState | null>(null); @@ -52,6 +54,7 @@ export function ImportYamlDialog({ setLoading(false); setError(undefined); setValidationErrors(undefined); + setPreservedValues([]); setStep('input'); setParsedConfig(null); setTargetMode('base'); @@ -117,6 +120,7 @@ export function ImportYamlDialog({ if (result.appConfig && typeof result.appConfig === 'object') { setParsedConfig(result.appConfig as Record); + setPreservedValues(result.preservedValues ?? []); setStep('target'); } } catch (err) { @@ -277,6 +281,17 @@ export function ImportYamlDialog({ {step === 'target' && (
+ {preservedValues.length > 0 && ( +
+ p.value))].join(', '), + })} + /> +
+ )}

{localize('com_config_import_target')}

diff --git a/src/components/configuration/__tests__/ImportYamlDialog.test.tsx b/src/components/configuration/__tests__/ImportYamlDialog.test.tsx new file mode 100644 index 00000000..453a698f --- /dev/null +++ b/src/components/configuration/__tests__/ImportYamlDialog.test.tsx @@ -0,0 +1,129 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { parseImportedYaml } from '@/server'; +import { ImportYamlDialog } from '../ImportYamlDialog'; + +vi.mock('@/hooks/useLocalize', () => { + const localize = (key: string, options?: Record) => + options ? `${key} ${Object.values(options).join(' ')}` : key; + return { default: () => localize, useLocalize: () => localize }; +}); + +vi.mock('@/server', () => ({ + parseImportedYaml: vi.fn(), + createRoleFn: vi.fn(), + createGroupFn: vi.fn(), + availableScopesOptions: { + queryKey: ['availableScopes'], + queryFn: async () => [], + }, +})); + +interface MockChildrenProps { + children?: React.ReactNode; +} +interface MockDialogProps extends MockChildrenProps { + open?: boolean; +} +interface MockDialogContentProps extends MockChildrenProps { + title?: string; +} +interface MockButtonProps { + label: string; + onClick?: () => void; + disabled?: boolean; +} +interface MockAlertProps { + text: string; +} + +vi.mock('@clickhouse/click-ui', () => { + const Dialog = ({ open, children }: MockDialogProps) => (open ?
{children}
: null); + Dialog.Content = ({ title, children }: MockDialogContentProps) => ( +
+ {title} + {children} +
+ ); + const Tabs = ({ children }: MockChildrenProps) =>
{children}
; + Tabs.TriggersList = ({ children }: MockChildrenProps) =>
{children}
; + Tabs.Trigger = ({ children }: MockChildrenProps) => ; + Tabs.Content = ({ children }: MockChildrenProps) =>
{children}
; + return { + Dialog, + Tabs, + Icon: ({ name }: { name: string }) => {name}, + Alert: ({ text }: MockAlertProps) =>
{text}
, + Button: ({ label, onClick, disabled }: MockButtonProps) => ( + + ), + }; +}); + +const parseImportedYamlMock = vi.mocked(parseImportedYaml); + +function renderDialog() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + , + ); +} + +async function validateYaml() { + fireEvent.change(screen.getByLabelText('com_config_import_paste'), { + target: { value: 'version: 1.3.12' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'com_config_import_validate' })); + await screen.findByRole('radiogroup'); +} + +describe('ImportYamlDialog preserved values notice', () => { + beforeEach(() => { + parseImportedYamlMock.mockReset(); + }); + + it('shows a non-blocking notice listing values the panel does not recognize', async () => { + parseImportedYamlMock.mockResolvedValue({ + success: true, + error: undefined, + validationErrors: undefined, + preservedValues: [ + { path: 'endpoints.agents.capabilities.4', value: 'subagents' }, + { path: 'endpoints.agents.capabilities.7', value: 'skills' }, + ], + appConfig: { version: '1.3.12' }, + }); + + renderDialog(); + await validateYaml(); + + const notice = screen.getByRole('status'); + expect(notice).toHaveTextContent('com_config_import_preserved_notice 2 subagents, skills'); + expect(screen.getByRole('button', { name: 'com_config_import_apply' })).not.toBeDisabled(); + }); + + it('shows no notice when every value is recognized', async () => { + parseImportedYamlMock.mockResolvedValue({ + success: true, + error: undefined, + validationErrors: undefined, + preservedValues: undefined, + appConfig: { version: '1.3.12' }, + }); + + renderDialog(); + await validateYaml(); + + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/configuration/fields/ListField.tsx b/src/components/configuration/fields/ListField.tsx index 2c6edf3e..34c92f25 100644 --- a/src/components/configuration/fields/ListField.tsx +++ b/src/components/configuration/fields/ListField.tsx @@ -30,6 +30,10 @@ export function ListField({ const resolvedPlaceholder = placeholder ?? localize('com_ui_enter_value'); const resolvedItemLabel = itemLabel ?? localize('com_ui_item'); + const knownOptionValues = options ? new Set(options.map((o) => o.value)) : null; + const selectedKnownCount = knownOptionValues + ? new Set(values.filter((v) => knownOptionValues.has(v))).size + : 0; const handleAdd = () => { if (options) { @@ -76,6 +80,7 @@ export function ListField({ aria-label={itemLabel} className="config-input flex-1" > + {!knownOptionValues?.has(value) && } {options.map((opt) => (