Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions src/components/configuration/ImportYamlDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string | number>) =>
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 ? <div>{children}</div> : null);
Dialog.Content = ({ title, children }: MockDialogContentProps) => (
<div>
{title}
{children}
</div>
);
const Tabs = ({ children }: MockChildrenProps) => <div>{children}</div>;
Tabs.TriggersList = ({ children }: MockChildrenProps) => <div>{children}</div>;
Tabs.Trigger = ({ children }: MockChildrenProps) => <button type="button">{children}</button>;
Tabs.Content = ({ children }: MockChildrenProps) => <div>{children}</div>;
return {
Dialog,
Tabs,
Icon: ({ name }: { name: string }) => <span>{name}</span>,
Alert: ({ text }: MockAlertProps) => <div role="status">{text}</div>,
Button: ({ label, onClick, disabled }: MockButtonProps) => (
<button onClick={onClick} disabled={disabled}>
{label}
</button>
),
};
});

const parseImportedYamlMock = vi.mocked(parseImportedYaml);

function renderDialog() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={queryClient}>
<ImportYamlDialog
open
onClose={vi.fn()}
onImport={vi.fn()}
onImportAsProfile={vi.fn().mockResolvedValue(undefined)}
/>
</QueryClientProvider>,
);
}

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();
});
});
15 changes: 15 additions & 0 deletions src/components/configuration/ImportYamlDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -24,6 +25,7 @@ export function ImportYamlDialog({
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>();
const [validationErrors, setValidationErrors] = useState<t.ImportValidationError[]>();
const [preservedValues, setPreservedValues] = useState<t.ImportPreservedValue[]>([]);

const [step, setStep] = useState<t.ImportStep>('input');
const [parsedConfig, setParsedConfig] = useState<Record<string, t.ConfigValue> | null>(null);
Expand Down Expand Up @@ -52,6 +54,7 @@ export function ImportYamlDialog({
setLoading(false);
setError(undefined);
setValidationErrors(undefined);
setPreservedValues([]);
setStep('input');
setParsedConfig(null);
setTargetMode('base');
Expand Down Expand Up @@ -117,6 +120,7 @@ export function ImportYamlDialog({

if (result.appConfig && typeof result.appConfig === 'object') {
setParsedConfig(result.appConfig as Record<string, t.ConfigValue>);
setPreservedValues(result.preservedValues ?? []);
setStep('target');
}
} catch (err) {
Expand Down Expand Up @@ -277,6 +281,17 @@ export function ImportYamlDialog({

{step === 'target' && (
<div ref={targetRef}>
{preservedValues.length > 0 && (
<div className="mb-3">
<InfoBanner
dismissible={false}
text={localize('com_config_import_preserved_notice', {
count: preservedValues.length,
values: [...new Set(preservedValues.map((p) => p.value))].join(', '),
})}
/>
</div>
)}
<p className="mb-3 text-sm text-(--cui-color-text-muted)">
{localize('com_config_import_target')}
</p>
Expand Down
111 changes: 111 additions & 0 deletions src/components/configuration/fields/ListField.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { ListField } from './ListField';

vi.mock('@/hooks/useLocalize', () => ({
default: () => (key: string) => key,
useLocalize: () => (key: string) => key,
}));

interface MockButtonProps {
label: string;
onClick?: () => void;
disabled?: boolean;
}
interface MockIconButtonProps {
onClick?: () => void;
'aria-label'?: string;
}

vi.mock('@clickhouse/click-ui', () => ({
Button: ({ label, onClick, disabled }: MockButtonProps) => (
<button onClick={onClick} disabled={disabled}>
{label}
</button>
),
IconButton: ({ onClick, 'aria-label': ariaLabel }: MockIconButtonProps) => (
<button onClick={onClick} aria-label={ariaLabel} />
),
}));

const capabilityOptions = [
{ label: 'execute_code', value: 'execute_code' },
{ label: 'web_search', value: 'web_search' },
{ label: 'tools', value: 'tools' },
];

describe('ListField with enum options', () => {
it('renders an unknown value as a visible selected option', () => {
render(
<ListField
id="capabilities"
values={['execute_code', 'capability_from_newer_librechat']}
onChange={vi.fn()}
options={capabilityOptions}
/>,
);

const selects = screen.getAllByRole('combobox');
expect((selects[1] as HTMLSelectElement).value).toBe('capability_from_newer_librechat');
expect(
screen.getByRole('option', { name: 'capability_from_newer_librechat' }),
).toBeInTheDocument();
});

it('does not render an extra option when all values are known', () => {
render(
<ListField
id="capabilities"
values={['execute_code', 'web_search']}
onChange={vi.fn()}
options={capabilityOptions}
/>,
);

expect(screen.getAllByRole('option')).toHaveLength(capabilityOptions.length * 2);
});

it('keeps the unknown value when another row is edited', () => {
const onChange = vi.fn();
render(
<ListField
id="capabilities"
values={['execute_code', 'capability_from_newer_librechat']}
onChange={onChange}
options={capabilityOptions}
/>,
);

fireEvent.change(screen.getAllByRole('combobox')[0], { target: { value: 'web_search' } });
expect(onChange).toHaveBeenCalledWith(['web_search', 'capability_from_newer_librechat']);
});

it('keeps the unknown value when another row is removed', () => {
const onChange = vi.fn();
render(
<ListField
id="capabilities"
values={['execute_code', 'capability_from_newer_librechat']}
onChange={onChange}
options={capabilityOptions}
/>,
);

fireEvent.click(screen.getByRole('button', { name: 'com_ui_delete com_ui_item 1' }));
expect(onChange).toHaveBeenCalledWith(['capability_from_newer_librechat']);
});

it('shows the unknown value as plain text when disabled', () => {
render(
<ListField
id="capabilities"
values={['capability_from_newer_librechat']}
onChange={vi.fn()}
options={capabilityOptions}
disabled
/>,
);

expect(screen.getByText('capability_from_newer_librechat')).toBeInTheDocument();
});
});
2 changes: 2 additions & 0 deletions src/components/configuration/fields/ListField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ 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 handleAdd = () => {
if (options) {
Expand Down Expand Up @@ -76,6 +77,7 @@ export function ListField({
aria-label={itemLabel}
className="config-input flex-1"
>
{!knownOptionValues?.has(value) && <option value={value}>{value}</option>}
Comment thread
dustinhealy marked this conversation as resolved.
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
Expand Down
1 change: 1 addition & 0 deletions src/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@
"com_config_import_validate": "Validate & import",
"com_config_import_success": "Configuration imported successfully.",
"com_config_import_error": "Import failed",
"com_config_import_preserved_notice": "Imported with {{count}} values this panel version does not recognize: {{values}}. They were preserved as-is and may come from a newer LibreChat release.",
"com_config_import_target": "Import target",
"com_config_import_apply": "Apply",
"com_config_import_back": "Back",
Expand Down
Loading