Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
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
129 changes: 129 additions & 0 deletions src/components/configuration/__tests__/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';
Comment thread
dustinhealy marked this conversation as resolved.
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();
});
});
7 changes: 6 additions & 1 deletion src/components/configuration/fields/ListField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -76,6 +80,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 Expand Up @@ -113,7 +118,7 @@ export function ListField({
);
})}

{!disabled && (!options || values.length < options.length) && (
{!disabled && (!options || selectedKnownCount < options.length) && (
<AddItemButton
label={localize('com_ui_add_item', { item: resolvedItemLabel })}
onClick={handleAdd}
Expand Down
2 changes: 2 additions & 0 deletions src/components/configuration/fields/SelectField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export function SelectField({
'aria-label': ariaLabel,
}: t.SelectFieldProps) {
const localize = useLocalize();
const isUnknownValue = value !== '' && !options.some((o) => o.value === value);

return (
<div className="select-field-a11y max-w-75" id={id}>
Expand All @@ -22,6 +23,7 @@ export function SelectField({
disabled={disabled}
aria-label={ariaLabel}
>
{isUnknownValue && <Select.Item value={value}>{value}</Select.Item>}
{options.map((option) => (
<Select.Item key={option.value} value={option.value}>
{option.label}
Expand Down
144 changes: 144 additions & 0 deletions src/components/configuration/fields/__tests__/ListField.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
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 Add when a known option remains unselected despite an unknown value', () => {
const onChange = vi.fn();
render(
<ListField
id="capabilities"
values={['capability_from_newer_librechat', 'execute_code', 'web_search']}
onChange={onChange}
options={capabilityOptions}
/>,
);

fireEvent.click(screen.getByRole('button', { name: 'com_ui_add_item' }));
expect(onChange).toHaveBeenCalledWith([
'capability_from_newer_librechat',
'execute_code',
'web_search',
'tools',
]);
});

it('hides Add when every known option is selected alongside an unknown value', () => {
render(
<ListField
id="capabilities"
values={['capability_from_newer_librechat', 'execute_code', 'web_search', 'tools']}
onChange={vi.fn()}
options={capabilityOptions}
/>,
);

expect(screen.queryByRole('button', { name: 'com_ui_add_item' })).not.toBeInTheDocument();
});

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();
});
});
Loading