mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
refactor(frontend): settings modal (#1256)
* allow arrow functions for components * initial commit - BaseModal * initial commit - SettingsForm * extend tests and component * extend to support language * refactor tests * move files and separate component/tests * extend functionality * refactor * major refactor and flip flops * add tests * fix styles and names * add loading state * remove old SettingModal * refactor component into smaller ones * fix model input * revert eslint rule to allow multiple function definitions for components and remove unused helper function * add new i18n key for language placeholder --------- Co-authored-by: Robert Brennan <accounts@rbren.io>
This commit is contained in:
parent
454e9613b0
commit
1f2a845feb
@ -43,7 +43,7 @@
|
||||
"required": {
|
||||
"some": [ "nesting", "id" ]
|
||||
}
|
||||
}],
|
||||
}],
|
||||
"react/no-array-index-key": "off"
|
||||
},"parserOptions": {
|
||||
"project": ["**/tsconfig.json"]
|
||||
|
||||
@ -1,18 +1,19 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import "./App.css";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
import { useDisclosure } from "@nextui-org/react";
|
||||
import CogTooth from "./assets/cog-tooth";
|
||||
import ChatInterface from "./components/ChatInterface";
|
||||
import Errors from "./components/Errors";
|
||||
import LoadMessageModal from "./components/LoadMessageModal";
|
||||
import { Container, Orientation } from "./components/Resizable";
|
||||
import SettingModal from "./components/SettingModal";
|
||||
import Terminal from "./components/Terminal";
|
||||
import Workspace from "./components/Workspace";
|
||||
import { fetchMsgTotal } from "./services/session";
|
||||
import { initializeAgent } from "./services/settingsService";
|
||||
import Socket from "./services/socket";
|
||||
import { ResFetchMsgTotal } from "./types/ResponseType";
|
||||
import SettingsModal from "./components/settings/SettingsModal";
|
||||
|
||||
interface Props {
|
||||
setSettingOpen: (isOpen: boolean) => void;
|
||||
@ -35,10 +36,11 @@ function LeftNav({ setSettingOpen }: Props): JSX.Element {
|
||||
let initOnce = false;
|
||||
|
||||
function App(): JSX.Element {
|
||||
const [settingOpen, setSettingOpen] = useState(false);
|
||||
const [isWarned, setIsWarned] = useState(false);
|
||||
const [loadMsgWarning, setLoadMsgWarning] = useState(false);
|
||||
|
||||
const { isOpen, onOpen, onOpenChange } = useDisclosure();
|
||||
|
||||
const getMsgTotal = () => {
|
||||
if (isWarned) return;
|
||||
fetchMsgTotal()
|
||||
@ -62,14 +64,10 @@ function App(): JSX.Element {
|
||||
getMsgTotal();
|
||||
}, []);
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setSettingOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen flex flex-col">
|
||||
<div className="flex grow bg-neutral-900 text-white min-h-0">
|
||||
<LeftNav setSettingOpen={setSettingOpen} />
|
||||
<LeftNav setSettingOpen={onOpen} />
|
||||
<Container
|
||||
orientation={Orientation.VERTICAL}
|
||||
className="grow p-3 py-3 pr-3 min-w-0"
|
||||
@ -93,7 +91,7 @@ function App(): JSX.Element {
|
||||
{/* This div is for the footer that will be added later
|
||||
<div className="h-8 w-full border-t border-border px-2" />
|
||||
*/}
|
||||
<SettingModal isOpen={settingOpen} onClose={handleCloseModal} />
|
||||
<SettingsModal isOpen={isOpen} onOpenChange={onOpenChange} />
|
||||
<LoadMessageModal
|
||||
isOpen={loadMsgWarning}
|
||||
onClose={() => setLoadMsgWarning(false)}
|
||||
|
||||
@ -1,154 +0,0 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
Autocomplete,
|
||||
AutocompleteItem,
|
||||
Button,
|
||||
Select,
|
||||
SelectItem,
|
||||
} from "@nextui-org/react";
|
||||
import { KeyboardEvent } from "@react-types/shared/src/events";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
fetchAgents,
|
||||
fetchModels,
|
||||
saveSettings,
|
||||
getCurrentSettings,
|
||||
Settings,
|
||||
} from "../services/settingsService";
|
||||
import { I18nKey } from "../i18n/declaration";
|
||||
import { AvailableLanguages } from "../i18n";
|
||||
import { ArgConfigType } from "../types/ConfigType";
|
||||
import ODModal from "./ODModal";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function InnerSettingModal({ isOpen, onClose }: Props): JSX.Element {
|
||||
const currentSettings: Settings = getCurrentSettings();
|
||||
const [model, setModel] = useState(currentSettings[ArgConfigType.LLM_MODEL]);
|
||||
const [inputModel, setInputModel] = useState(
|
||||
currentSettings[ArgConfigType.LLM_MODEL],
|
||||
);
|
||||
const [agent, setAgent] = useState(currentSettings[ArgConfigType.AGENT]);
|
||||
const [language, setLanguage] = useState(
|
||||
currentSettings[ArgConfigType.LANGUAGE],
|
||||
);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [supportedModels, setSupportedModels] = useState([]);
|
||||
const [supportedAgents, setSupportedAgents] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchModels().then((fetchedModels) => {
|
||||
const sortedModels = fetchedModels.sort(); // Sorting the models alphabetically
|
||||
setSupportedModels(sortedModels);
|
||||
});
|
||||
|
||||
fetchAgents().then((fetchedAgents) => {
|
||||
setSupportedAgents(fetchedAgents);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSaveCfg = () => {
|
||||
saveSettings({
|
||||
[ArgConfigType.LLM_MODEL]: model ?? inputModel,
|
||||
[ArgConfigType.AGENT]: agent,
|
||||
[ArgConfigType.LANGUAGE]: language,
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
const customFilter = (item: string, input: string) =>
|
||||
item.toLowerCase().includes(input.toLowerCase());
|
||||
|
||||
return (
|
||||
<ODModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={t(I18nKey.CONFIGURATION$MODAL_TITLE)}
|
||||
subtitle={t(I18nKey.CONFIGURATION$MODAL_SUB_TITLE)}
|
||||
hideCloseButton
|
||||
backdrop="blur"
|
||||
size="sm"
|
||||
primaryAction={
|
||||
<Button className="bg-primary rounded-small" onPress={handleSaveCfg}>
|
||||
{t(I18nKey.CONFIGURATION$MODAL_SAVE_BUTTON_LABEL)}
|
||||
</Button>
|
||||
}
|
||||
secondaryAction={
|
||||
<Button className="bg-neutral-500 rounded-small" onPress={onClose}>
|
||||
{t(I18nKey.CONFIGURATION$MODAL_CLOSE_BUTTON_LABEL)}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<>
|
||||
<Autocomplete
|
||||
defaultItems={supportedModels.map((v: string) => ({
|
||||
label: v,
|
||||
value: v,
|
||||
}))}
|
||||
label={t(I18nKey.CONFIGURATION$MODEL_SELECT_LABEL)}
|
||||
placeholder={t(I18nKey.CONFIGURATION$MODEL_SELECT_PLACEHOLDER)}
|
||||
selectedKey={model}
|
||||
onSelectionChange={(key) => {
|
||||
setModel(key as string);
|
||||
}}
|
||||
onInputChange={(e) => setInputModel(e)}
|
||||
onKeyDown={(e: KeyboardEvent) => e.continuePropagation()}
|
||||
defaultFilter={customFilter}
|
||||
defaultInputValue={inputModel}
|
||||
allowsCustomValue
|
||||
>
|
||||
{(item: { label: string; value: string }) => (
|
||||
<AutocompleteItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</AutocompleteItem>
|
||||
)}
|
||||
</Autocomplete>
|
||||
<Autocomplete
|
||||
defaultItems={supportedAgents.map((v: string) => ({
|
||||
label: v,
|
||||
value: v,
|
||||
}))}
|
||||
label={t(I18nKey.CONFIGURATION$AGENT_SELECT_LABEL)}
|
||||
placeholder={t(I18nKey.CONFIGURATION$AGENT_SELECT_PLACEHOLDER)}
|
||||
defaultSelectedKey={agent}
|
||||
onSelectionChange={(key) => {
|
||||
setAgent(key as string);
|
||||
}}
|
||||
onKeyDown={(e: KeyboardEvent) => e.continuePropagation()}
|
||||
defaultFilter={customFilter}
|
||||
>
|
||||
{(item: { label: string; value: string }) => (
|
||||
<AutocompleteItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</AutocompleteItem>
|
||||
)}
|
||||
</Autocomplete>
|
||||
<Select
|
||||
selectionMode="single"
|
||||
onChange={(e) => setLanguage(e.target.value)}
|
||||
selectedKeys={[language || ""]}
|
||||
label={t(I18nKey.CONFIGURATION$LANGUAGE_SELECT_LABEL)}
|
||||
>
|
||||
{AvailableLanguages.map((lang) => (
|
||||
<SelectItem key={lang.value} value={lang.value}>
|
||||
{lang.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</>
|
||||
</ODModal>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingModal({ isOpen, onClose }: Props): JSX.Element {
|
||||
// Do not render the modal if it is not open, prevents reading empty from localStorage after initialization
|
||||
if (!isOpen) return <div />;
|
||||
return <InnerSettingModal isOpen={isOpen} onClose={onClose} />;
|
||||
}
|
||||
|
||||
export default SettingModal;
|
||||
98
frontend/src/components/base-modal/BaseModal.test.tsx
Normal file
98
frontend/src/components/base-modal/BaseModal.test.tsx
Normal file
@ -0,0 +1,98 @@
|
||||
import React from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { act } from "react-dom/test-utils";
|
||||
import BaseModal from "./BaseModal";
|
||||
|
||||
describe("BaseModal", () => {
|
||||
it("should render if the modal is open", () => {
|
||||
const { rerender } = render(
|
||||
<BaseModal isOpen={false} onOpenChange={vi.fn} title="Settings" />,
|
||||
);
|
||||
expect(screen.queryByText("Settings")).not.toBeInTheDocument();
|
||||
|
||||
rerender(<BaseModal title="Settings" onOpenChange={vi.fn} isOpen />);
|
||||
expect(screen.getByText("Settings")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render an optional subtitle", () => {
|
||||
render(
|
||||
<BaseModal
|
||||
isOpen
|
||||
onOpenChange={vi.fn}
|
||||
title="Settings"
|
||||
subtitle="Subtitle"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Subtitle")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render actions", () => {
|
||||
const onPrimaryClickMock = vi.fn();
|
||||
const onSecondaryClickMock = vi.fn();
|
||||
|
||||
const primaryAction = {
|
||||
action: onPrimaryClickMock,
|
||||
label: "Save",
|
||||
};
|
||||
|
||||
const secondaryAction = {
|
||||
action: onSecondaryClickMock,
|
||||
label: "Cancel",
|
||||
};
|
||||
|
||||
render(
|
||||
<BaseModal
|
||||
isOpen
|
||||
onOpenChange={vi.fn}
|
||||
title="Settings"
|
||||
actions={[primaryAction, secondaryAction]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Save")).toBeInTheDocument();
|
||||
expect(screen.getByText("Cancel")).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
userEvent.click(screen.getByText("Save"));
|
||||
});
|
||||
expect(onPrimaryClickMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => {
|
||||
userEvent.click(screen.getByText("Cancel"));
|
||||
});
|
||||
expect(onSecondaryClickMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should close the modal after an action is performed", () => {
|
||||
const onOpenChangeMock = vi.fn();
|
||||
render(
|
||||
<BaseModal
|
||||
isOpen
|
||||
onOpenChange={onOpenChangeMock}
|
||||
title="Settings"
|
||||
actions={[
|
||||
{
|
||||
label: "Save",
|
||||
action: () => {},
|
||||
closeAfterAction: true,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
userEvent.click(screen.getByText("Save"));
|
||||
});
|
||||
expect(onOpenChangeMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should render children", () => {
|
||||
render(
|
||||
<BaseModal isOpen onOpenChange={vi.fn} title="Settings">
|
||||
<div>Children</div>
|
||||
</BaseModal>,
|
||||
);
|
||||
expect(screen.getByText("Children")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
66
frontend/src/components/base-modal/BaseModal.tsx
Normal file
66
frontend/src/components/base-modal/BaseModal.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
} from "@nextui-org/react";
|
||||
import { Action, FooterContent } from "./FooterContent";
|
||||
import { HeaderContent } from "./HeaderContent";
|
||||
|
||||
interface BaseModalProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
actions?: Action[];
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
function BaseModal({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
title,
|
||||
subtitle,
|
||||
actions,
|
||||
children,
|
||||
}: BaseModalProps) {
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
title={title}
|
||||
backdrop="blur"
|
||||
hideCloseButton
|
||||
size="sm"
|
||||
className="bg-neutral-900 rounded-large"
|
||||
>
|
||||
<ModalContent className="max-w-[24rem] p-[40px]">
|
||||
{(closeModal) => (
|
||||
<>
|
||||
<ModalHeader className="flex flex-col p-0">
|
||||
<HeaderContent title={title} subtitle={subtitle} />
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody className="px-0 py-[20px]">{children}</ModalBody>
|
||||
|
||||
{actions && actions.length > 0 && (
|
||||
<ModalFooter className="flex-col flex justify-start p-0">
|
||||
<FooterContent actions={actions} closeModal={closeModal} />
|
||||
</ModalFooter>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
BaseModal.defaultProps = {
|
||||
subtitle: undefined,
|
||||
actions: [],
|
||||
children: null,
|
||||
};
|
||||
|
||||
export default BaseModal;
|
||||
34
frontend/src/components/base-modal/FooterContent.tsx
Normal file
34
frontend/src/components/base-modal/FooterContent.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { Button } from "@nextui-org/react";
|
||||
import React from "react";
|
||||
|
||||
export interface Action {
|
||||
action: () => void;
|
||||
label: string;
|
||||
className?: string;
|
||||
closeAfterAction?: boolean;
|
||||
}
|
||||
|
||||
interface FooterContentProps {
|
||||
actions: Action[];
|
||||
closeModal: () => void;
|
||||
}
|
||||
|
||||
export function FooterContent({ actions, closeModal }: FooterContentProps) {
|
||||
return (
|
||||
<>
|
||||
{actions.map(({ action, label, className, closeAfterAction }) => (
|
||||
<Button
|
||||
key={label}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
action();
|
||||
if (closeAfterAction) closeModal();
|
||||
}}
|
||||
className={className}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
21
frontend/src/components/base-modal/HeaderContent.tsx
Normal file
21
frontend/src/components/base-modal/HeaderContent.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import React from "react";
|
||||
|
||||
interface HeaderContentProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
export function HeaderContent({ title, subtitle }: HeaderContentProps) {
|
||||
return (
|
||||
<>
|
||||
<h3>{title}</h3>
|
||||
{subtitle && (
|
||||
<span className="text-neutral-400 text-sm font-light">{subtitle}</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
HeaderContent.defaultProps = {
|
||||
subtitle: undefined,
|
||||
};
|
||||
@ -0,0 +1,64 @@
|
||||
import { render, screen, act } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { AutocompleteCombobox } from "./AutocompleteCombobox";
|
||||
|
||||
const onChangeMock = vi.fn();
|
||||
|
||||
const renderComponent = () =>
|
||||
render(
|
||||
<AutocompleteCombobox
|
||||
ariaLabel="model"
|
||||
items={[
|
||||
{ value: "m1", label: "model1" },
|
||||
{ value: "m2", label: "model2" },
|
||||
{ value: "m3", label: "model3" },
|
||||
]}
|
||||
defaultKey="m1"
|
||||
onChange={onChangeMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
describe("AutocompleteCombobox", () => {
|
||||
it("should render a combobox with the default value", () => {
|
||||
renderComponent();
|
||||
|
||||
const modelInput = screen.getByRole("combobox", { name: "model" });
|
||||
expect(modelInput).toHaveValue("model1");
|
||||
});
|
||||
|
||||
it("should open a dropdown with the available values", () => {
|
||||
renderComponent();
|
||||
|
||||
const modelInput = screen.getByRole("combobox", { name: "model" });
|
||||
|
||||
expect(screen.queryByText("model2")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("model3")).not.toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
userEvent.click(modelInput);
|
||||
});
|
||||
|
||||
expect(screen.getByText("model2")).toBeInTheDocument();
|
||||
expect(screen.getByText("model3")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call the onChange handler when a new value is selected", () => {
|
||||
renderComponent();
|
||||
|
||||
const modelInput = screen.getByRole("combobox", { name: "model" });
|
||||
expect(modelInput).toHaveValue("model1");
|
||||
|
||||
act(() => {
|
||||
userEvent.click(modelInput);
|
||||
});
|
||||
|
||||
const model2 = screen.getByText("model2");
|
||||
act(() => {
|
||||
userEvent.click(model2);
|
||||
});
|
||||
|
||||
expect(modelInput).toHaveValue("model2");
|
||||
expect(onChangeMock).toHaveBeenCalledWith("model2");
|
||||
});
|
||||
});
|
||||
63
frontend/src/components/settings/AutocompleteCombobox.tsx
Normal file
63
frontend/src/components/settings/AutocompleteCombobox.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
import { Autocomplete, AutocompleteItem } from "@nextui-org/react";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "../../i18n/declaration";
|
||||
|
||||
type Label = "model" | "agent" | "language";
|
||||
|
||||
const LABELS: Record<Label, I18nKey> = {
|
||||
model: I18nKey.CONFIGURATION$MODEL_SELECT_LABEL,
|
||||
agent: I18nKey.CONFIGURATION$AGENT_SELECT_LABEL,
|
||||
language: I18nKey.CONFIGURATION$LANGUAGE_SELECT_LABEL,
|
||||
};
|
||||
|
||||
const PLACEHOLDERS: Record<Label, I18nKey> = {
|
||||
model: I18nKey.CONFIGURATION$MODEL_SELECT_PLACEHOLDER,
|
||||
agent: I18nKey.CONFIGURATION$AGENT_SELECT_PLACEHOLDER,
|
||||
language: I18nKey.CONFIGURATION$LANGUAGE_SELECT_PLACEHOLDER,
|
||||
};
|
||||
|
||||
type AutocompleteItemType = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
interface AutocompleteComboboxProps {
|
||||
ariaLabel: Label;
|
||||
items: AutocompleteItemType[];
|
||||
defaultKey: string;
|
||||
onChange: (key: string) => void;
|
||||
allowCustomValue?: boolean;
|
||||
}
|
||||
|
||||
export function AutocompleteCombobox({
|
||||
ariaLabel,
|
||||
items,
|
||||
defaultKey,
|
||||
onChange,
|
||||
allowCustomValue = false,
|
||||
}: AutocompleteComboboxProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Autocomplete
|
||||
aria-label={ariaLabel}
|
||||
label={t(LABELS[ariaLabel])}
|
||||
placeholder={t(PLACEHOLDERS[ariaLabel])}
|
||||
defaultItems={items}
|
||||
defaultSelectedKey={defaultKey}
|
||||
allowsCustomValue={allowCustomValue}
|
||||
onInputChange={(value) => {
|
||||
onChange(value);
|
||||
}}
|
||||
>
|
||||
{(item) => (
|
||||
<AutocompleteItem key={item.value}>{item.label}</AutocompleteItem>
|
||||
)}
|
||||
</Autocomplete>
|
||||
);
|
||||
}
|
||||
|
||||
AutocompleteCombobox.defaultProps = {
|
||||
allowCustomValue: false,
|
||||
};
|
||||
101
frontend/src/components/settings/SettingsForm.test.tsx
Normal file
101
frontend/src/components/settings/SettingsForm.test.tsx
Normal file
@ -0,0 +1,101 @@
|
||||
import React from "react";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import SettingsForm from "./SettingsForm";
|
||||
|
||||
const onModelChangeMock = vi.fn();
|
||||
const onAgentChangeMock = vi.fn();
|
||||
const onLanguageChangeMock = vi.fn();
|
||||
|
||||
const renderSettingsForm = (settings: Partial<Settings>) => {
|
||||
render(
|
||||
<SettingsForm
|
||||
settings={settings}
|
||||
models={["model1", "model2", "model3"]}
|
||||
agents={["agent1", "agent2", "agent3"]}
|
||||
onModelChange={onModelChangeMock}
|
||||
onAgentChange={onAgentChangeMock}
|
||||
onLanguageChange={onLanguageChangeMock}
|
||||
/>,
|
||||
);
|
||||
};
|
||||
|
||||
describe("SettingsForm", () => {
|
||||
it("should display the first values in the array by default", () => {
|
||||
renderSettingsForm({});
|
||||
|
||||
const modelInput = screen.getByRole("combobox", { name: "model" });
|
||||
const agentInput = screen.getByRole("combobox", { name: "agent" });
|
||||
const languageInput = screen.getByRole("combobox", { name: "language" });
|
||||
|
||||
expect(modelInput).toHaveValue("model1");
|
||||
expect(agentInput).toHaveValue("agent1");
|
||||
expect(languageInput).toHaveValue("English");
|
||||
});
|
||||
|
||||
it("should display the existing values if it they are present", () => {
|
||||
renderSettingsForm({
|
||||
LLM_MODEL: "model2",
|
||||
AGENT: "agent2",
|
||||
LANGUAGE: "es",
|
||||
});
|
||||
|
||||
const modelInput = screen.getByRole("combobox", { name: "model" });
|
||||
const agentInput = screen.getByRole("combobox", { name: "agent" });
|
||||
const languageInput = screen.getByRole("combobox", { name: "language" });
|
||||
|
||||
expect(modelInput).toHaveValue("model2");
|
||||
expect(agentInput).toHaveValue("agent2");
|
||||
expect(languageInput).toHaveValue("Español");
|
||||
});
|
||||
|
||||
describe("onChange handlers", () => {
|
||||
it("should call the onModelChange handler when the model changes", () => {
|
||||
renderSettingsForm({});
|
||||
|
||||
const modelInput = screen.getByRole("combobox", { name: "model" });
|
||||
act(() => {
|
||||
userEvent.click(modelInput);
|
||||
});
|
||||
|
||||
const model3 = screen.getByText("model3");
|
||||
act(() => {
|
||||
userEvent.click(model3);
|
||||
});
|
||||
|
||||
expect(onModelChangeMock).toHaveBeenCalledWith("model3");
|
||||
});
|
||||
|
||||
it("should call the onAgentChange handler when the agent changes", () => {
|
||||
renderSettingsForm({});
|
||||
|
||||
const agentInput = screen.getByRole("combobox", { name: "agent" });
|
||||
act(() => {
|
||||
userEvent.click(agentInput);
|
||||
});
|
||||
|
||||
const agent3 = screen.getByText("agent3");
|
||||
act(() => {
|
||||
userEvent.click(agent3);
|
||||
});
|
||||
|
||||
expect(onAgentChangeMock).toHaveBeenCalledWith("agent3");
|
||||
});
|
||||
|
||||
it("should call the onLanguageChange handler when the language changes", () => {
|
||||
renderSettingsForm({});
|
||||
|
||||
const languageInput = screen.getByRole("combobox", { name: "language" });
|
||||
act(() => {
|
||||
userEvent.click(languageInput);
|
||||
});
|
||||
|
||||
const french = screen.getByText("Français");
|
||||
act(() => {
|
||||
userEvent.click(french);
|
||||
});
|
||||
|
||||
expect(onLanguageChangeMock).toHaveBeenCalledWith("Français");
|
||||
});
|
||||
});
|
||||
});
|
||||
48
frontend/src/components/settings/SettingsForm.tsx
Normal file
48
frontend/src/components/settings/SettingsForm.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import React from "react";
|
||||
import { AvailableLanguages } from "../../i18n";
|
||||
import { AutocompleteCombobox } from "./AutocompleteCombobox";
|
||||
|
||||
interface SettingsFormProps {
|
||||
settings: Partial<Settings>;
|
||||
models: string[];
|
||||
agents: string[];
|
||||
|
||||
onModelChange: (model: string) => void;
|
||||
onAgentChange: (agent: string) => void;
|
||||
onLanguageChange: (language: string) => void;
|
||||
}
|
||||
|
||||
function SettingsForm({
|
||||
settings,
|
||||
models,
|
||||
agents,
|
||||
onModelChange,
|
||||
onAgentChange,
|
||||
onLanguageChange,
|
||||
}: SettingsFormProps) {
|
||||
return (
|
||||
<>
|
||||
<AutocompleteCombobox
|
||||
ariaLabel="model"
|
||||
items={models.map((model) => ({ value: model, label: model }))}
|
||||
defaultKey={settings.LLM_MODEL || models[0]}
|
||||
onChange={onModelChange}
|
||||
allowCustomValue // user can type in a custom LLM model that is not in the list
|
||||
/>
|
||||
<AutocompleteCombobox
|
||||
ariaLabel="agent"
|
||||
items={agents.map((agent) => ({ value: agent, label: agent }))}
|
||||
defaultKey={settings.AGENT || agents[0]}
|
||||
onChange={onAgentChange}
|
||||
/>
|
||||
<AutocompleteCombobox
|
||||
ariaLabel="language"
|
||||
items={AvailableLanguages}
|
||||
defaultKey={settings.LANGUAGE || "en"}
|
||||
onChange={onLanguageChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default SettingsForm;
|
||||
136
frontend/src/components/settings/SettingsModal.test.tsx
Normal file
136
frontend/src/components/settings/SettingsModal.test.tsx
Normal file
@ -0,0 +1,136 @@
|
||||
import { waitFor, screen, act, render } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import React from "react";
|
||||
import { Mock } from "vitest";
|
||||
import {
|
||||
fetchModels,
|
||||
fetchAgents,
|
||||
saveSettings,
|
||||
getCurrentSettings,
|
||||
} from "../../services/settingsService";
|
||||
import SettingsModal from "./SettingsModal";
|
||||
|
||||
vi.mock("../../services/settingsService", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../../services/settingsService")>()),
|
||||
getCurrentSettings: vi.fn().mockReturnValue({}),
|
||||
saveSettings: vi.fn(),
|
||||
fetchModels: vi
|
||||
.fn()
|
||||
.mockResolvedValue(Promise.resolve(["model1", "model2", "model3"])),
|
||||
fetchAgents: vi
|
||||
.fn()
|
||||
.mockResolvedValue(Promise.resolve(["agent1", "agent2", "agent3"])),
|
||||
}));
|
||||
|
||||
describe("SettingsModal", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should fetch existing agents and models from the API", async () => {
|
||||
render(<SettingsModal isOpen onOpenChange={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchModels).toHaveBeenCalledTimes(1);
|
||||
expect(fetchAgents).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it.todo(
|
||||
"should display a loading spinner when fetching the models and agents",
|
||||
);
|
||||
|
||||
it("should close the modal when the cancel button is clicked", async () => {
|
||||
const onOpenChange = vi.fn();
|
||||
await act(async () =>
|
||||
render(<SettingsModal isOpen onOpenChange={onOpenChange} />),
|
||||
);
|
||||
|
||||
const cancelButton = screen.getByRole("button", {
|
||||
name: /MODAL_CLOSE_BUTTON_LABEL/i, // i18n key
|
||||
});
|
||||
|
||||
act(() => {
|
||||
userEvent.click(cancelButton);
|
||||
});
|
||||
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("should call saveSettings (and close) with the new values", async () => {
|
||||
const onOpenChangeMock = vi.fn();
|
||||
await act(async () =>
|
||||
render(<SettingsModal isOpen onOpenChange={onOpenChangeMock} />),
|
||||
);
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: /save/i });
|
||||
|
||||
const modelInput = screen.getByRole("combobox", { name: "model" });
|
||||
|
||||
act(() => {
|
||||
userEvent.click(modelInput);
|
||||
});
|
||||
|
||||
const model3 = screen.getByText("model3");
|
||||
|
||||
act(() => {
|
||||
userEvent.click(model3);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
userEvent.click(saveButton);
|
||||
});
|
||||
|
||||
expect(saveSettings).toHaveBeenCalledWith({
|
||||
LLM_MODEL: "model3",
|
||||
});
|
||||
expect(onOpenChangeMock).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
// This test does not seem to rerender the component correctly
|
||||
// Therefore, we cannot test the reset of the state
|
||||
it.skip("should reset state when the cancel button is clicked", async () => {
|
||||
(getCurrentSettings as Mock).mockReturnValue({
|
||||
LLM_MODEL: "model1",
|
||||
AGENT: "agent1",
|
||||
LANGUAGE: "English",
|
||||
});
|
||||
|
||||
const onOpenChange = vi.fn();
|
||||
const { rerender } = render(
|
||||
<SettingsModal isOpen onOpenChange={onOpenChange} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("combobox", { name: "model" })).toHaveValue(
|
||||
"model1",
|
||||
);
|
||||
});
|
||||
|
||||
const cancelButton = screen.getByRole("button", { name: /cancel/i });
|
||||
|
||||
const modelInput = screen.getByRole("combobox", { name: "model" });
|
||||
act(() => {
|
||||
userEvent.click(modelInput);
|
||||
});
|
||||
|
||||
const model3 = screen.getByText("model3");
|
||||
act(() => {
|
||||
userEvent.click(model3);
|
||||
});
|
||||
|
||||
expect(modelInput).toHaveValue("model3");
|
||||
|
||||
act(() => {
|
||||
userEvent.click(cancelButton);
|
||||
});
|
||||
|
||||
rerender(<SettingsModal isOpen onOpenChange={onOpenChange} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("combobox", { name: "model" })).toHaveValue(
|
||||
"model1",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
100
frontend/src/components/settings/SettingsModal.tsx
Normal file
100
frontend/src/components/settings/SettingsModal.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Spinner } from "@nextui-org/react";
|
||||
import BaseModal from "../base-modal/BaseModal";
|
||||
import SettingsForm from "./SettingsForm";
|
||||
import {
|
||||
fetchAgents,
|
||||
fetchModels,
|
||||
getCurrentSettings,
|
||||
saveSettings,
|
||||
} from "../../services/settingsService";
|
||||
import { I18nKey } from "../../i18n/declaration";
|
||||
import { AvailableLanguages } from "../../i18n";
|
||||
|
||||
interface SettingsProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
function SettingsModal({ isOpen, onOpenChange }: SettingsProps) {
|
||||
const { t } = useTranslation();
|
||||
const currentSettings = React.useMemo(() => getCurrentSettings(), []);
|
||||
|
||||
const [models, setModels] = React.useState<string[]>([]);
|
||||
const [agents, setAgents] = React.useState<string[]>([]);
|
||||
const [settings, setSettings] =
|
||||
React.useState<Partial<Settings>>(currentSettings);
|
||||
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
|
||||
React.useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
setModels(await fetchModels());
|
||||
setAgents(await fetchAgents());
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const handleModelChange = (model: string) => {
|
||||
setSettings((prev) => ({ ...prev, LLM_MODEL: model }));
|
||||
};
|
||||
|
||||
const handleAgentChange = (agent: string) => {
|
||||
setSettings((prev) => ({ ...prev, AGENT: agent }));
|
||||
};
|
||||
|
||||
const handleLanguageChange = (language: string) => {
|
||||
const key = AvailableLanguages.find(
|
||||
(lang) => lang.label === language,
|
||||
)?.value;
|
||||
|
||||
if (key) setSettings((prev) => ({ ...prev, LANGUAGE: key }));
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseModal
|
||||
isOpen={isOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t(I18nKey.CONFIGURATION$MODAL_TITLE)}
|
||||
subtitle={t(I18nKey.CONFIGURATION$MODAL_SUB_TITLE)}
|
||||
actions={[
|
||||
{
|
||||
label: t(I18nKey.CONFIGURATION$MODAL_SAVE_BUTTON_LABEL),
|
||||
action: () => {
|
||||
saveSettings(settings);
|
||||
},
|
||||
closeAfterAction: true,
|
||||
className: "bg-primary rounded-small",
|
||||
},
|
||||
{
|
||||
label: t(I18nKey.CONFIGURATION$MODAL_CLOSE_BUTTON_LABEL),
|
||||
action: () => {
|
||||
setSettings(currentSettings); // reset settings from any changes
|
||||
},
|
||||
closeAfterAction: true,
|
||||
className: "bg-neutral-500 rounded-small",
|
||||
},
|
||||
]}
|
||||
>
|
||||
{loading && <Spinner />}
|
||||
{!loading && (
|
||||
<SettingsForm
|
||||
settings={settings}
|
||||
models={models}
|
||||
agents={agents}
|
||||
onModelChange={handleModelChange}
|
||||
onAgentChange={handleAgentChange}
|
||||
onLanguageChange={handleLanguageChange}
|
||||
/>
|
||||
)}
|
||||
</BaseModal>
|
||||
);
|
||||
}
|
||||
|
||||
export default SettingsModal;
|
||||
@ -188,6 +188,20 @@
|
||||
"pt": "Idioma",
|
||||
"es": "Idioma"
|
||||
},
|
||||
"CONFIGURATION$LANGUAGE_SELECT_PLACEHOLDER": {
|
||||
"en": "Select a language",
|
||||
"zh-CN": "选择一种语言",
|
||||
"de": "Wähle eine Sprache",
|
||||
"ko-KR": "언어 선택",
|
||||
"no": "Velg et språk",
|
||||
"zh-TW": "選擇一種語言",
|
||||
"it": "Seleziona una lingua",
|
||||
"pt": "Selecione um idioma",
|
||||
"es": "Seleccionar un idioma",
|
||||
"ar": "حدد لغة",
|
||||
"fr": "Sélectionner une langue",
|
||||
"tr": "Dil Seç"
|
||||
},
|
||||
"CONFIGURATION$MODAL_CLOSE_BUTTON_LABEL": {
|
||||
"en": "Close",
|
||||
"zh-CN": "关闭",
|
||||
|
||||
5
frontend/src/services/settings.d.ts
vendored
Normal file
5
frontend/src/services/settings.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
type Settings = {
|
||||
LLM_MODEL: string;
|
||||
AGENT: string;
|
||||
LANGUAGE: string;
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user