feat(ui): uniformise les droplist dynamiques sur le style du picker d'agent
Introduit SmallDropdown comme composant partagé et le fait adopter par AgentsPanel, ModelServerSelect, OpenCodeModeFields, TemplateEditor et TicketViewportSelect, pour que toutes les listes déroulantes dynamiques (agent, modèle, template, viewport) partagent la même apparence que le sélecteur d'agent de la création de ticket. Refs #114 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@ -25,6 +25,11 @@ import { DIProvider } from "@/app/di";
|
||||
import { FirstRunWizard } from "./FirstRunWizard";
|
||||
import { DETECT_TIMEOUT_MS } from "./useFirstRun";
|
||||
|
||||
function chooseDropdownOption(label: string | RegExp, optionName: string | RegExp) {
|
||||
fireEvent.click(screen.getByLabelText(label));
|
||||
fireEvent.click(screen.getByRole("option", { name: optionName }));
|
||||
}
|
||||
|
||||
function renderWizard(
|
||||
profile: MockProfileGateway = new MockProfileGateway(),
|
||||
onDone = vi.fn(),
|
||||
@ -474,11 +479,11 @@ describe("FirstRunWizard — OpenCode cloud provider (ticket #92)", () => {
|
||||
await waitForLoaded();
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Provider cloud" }));
|
||||
|
||||
const providerSelect = await screen.findByLabelText(`${OPENCODE} provider`);
|
||||
const modelSelect = screen.getByLabelText(`${OPENCODE} model`) as HTMLSelectElement;
|
||||
await screen.findByLabelText(`${OPENCODE} provider`);
|
||||
const modelSelect = screen.getByLabelText(`${OPENCODE} model`) as HTMLButtonElement;
|
||||
expect(modelSelect.disabled).toBe(true);
|
||||
|
||||
fireEvent.change(providerSelect, { target: { value: "anthropic" } });
|
||||
chooseDropdownOption(`${OPENCODE} provider`, "Anthropic");
|
||||
expect(modelSelect.disabled).toBe(false);
|
||||
|
||||
const saveButton = screen.getByRole("button", {
|
||||
@ -486,7 +491,7 @@ describe("FirstRunWizard — OpenCode cloud provider (ticket #92)", () => {
|
||||
}) as HTMLButtonElement;
|
||||
expect(saveButton.disabled).toBe(true);
|
||||
|
||||
fireEvent.change(modelSelect, { target: { value: "claude-sonnet-5" } });
|
||||
chooseDropdownOption(`${OPENCODE} model`, "claude-sonnet-5");
|
||||
expect(saveButton.disabled).toBe(true);
|
||||
|
||||
fireEvent.change(screen.getByLabelText(`${OPENCODE} api key`), {
|
||||
@ -500,12 +505,9 @@ describe("FirstRunWizard — OpenCode cloud provider (ticket #92)", () => {
|
||||
await waitForLoaded();
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Provider cloud" }));
|
||||
|
||||
fireEvent.change(await screen.findByLabelText(`${OPENCODE} provider`), {
|
||||
target: { value: "anthropic" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), {
|
||||
target: { value: "claude-sonnet-5" },
|
||||
});
|
||||
await screen.findByLabelText(`${OPENCODE} provider`);
|
||||
chooseDropdownOption(`${OPENCODE} provider`, "Anthropic");
|
||||
chooseDropdownOption(`${OPENCODE} model`, "claude-sonnet-5");
|
||||
const apiKeyInput = screen.getByLabelText(
|
||||
`${OPENCODE} api key`,
|
||||
) as HTMLInputElement;
|
||||
@ -547,8 +549,8 @@ describe("FirstRunWizard — OpenCode custom cloud provider (ticket #92, dynamic
|
||||
|
||||
async function goToCloudCustomMode() {
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Provider cloud" }));
|
||||
const providerSelect = await screen.findByLabelText(`${OPENCODE} provider`);
|
||||
fireEvent.change(providerSelect, { target: { value: "__custom__" } });
|
||||
await screen.findByLabelText(`${OPENCODE} provider`);
|
||||
chooseDropdownOption(`${OPENCODE} provider`, "Autre / personnalisé…");
|
||||
}
|
||||
|
||||
it("selecting 'Autre / personnalisé' swaps the cascade for free-form fields", async () => {
|
||||
@ -904,8 +906,8 @@ describe("FirstRunWizard — several local OpenCode profiles (F36)", () => {
|
||||
expect(
|
||||
screen.queryByLabelText(`${CLONE1} local model server id`),
|
||||
).toBeNull();
|
||||
const select = await screen.findByLabelText(`${CLONE1} local model server`);
|
||||
fireEvent.change(select, { target: { value: server.id } });
|
||||
await screen.findByLabelText(`${CLONE1} local model server`);
|
||||
chooseDropdownOption(`${CLONE1} local model server`, /Local A/);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
||||
await waitFor(async () => {
|
||||
|
||||
@ -8,7 +8,7 @@ import type {
|
||||
OpenCodeProviderCatalogEntry,
|
||||
} from "@/domain";
|
||||
import { useGateways } from "@/app/di";
|
||||
import { Button, IconButton, Input, cn } from "@/shared";
|
||||
import { Button, IconButton, Input, SmallDropdown, cn } from "@/shared";
|
||||
import { ModelServerSelect } from "@/features/model-servers";
|
||||
import {
|
||||
defaultOpenCodeConfig,
|
||||
@ -300,12 +300,11 @@ function OpenCodeProviderFields({
|
||||
className="h-8 w-full rounded-md border border-border bg-raised px-3 text-xs text-content outline-none"
|
||||
/>
|
||||
)}
|
||||
<select
|
||||
<SmallDropdown
|
||||
aria-label={`${profile.name} provider`}
|
||||
value={providerId}
|
||||
disabled={!catalogReady}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
onChange={(v) => {
|
||||
if (v === CUSTOM_PROVIDER_VALUE) {
|
||||
setMode("custom");
|
||||
setProviderId("");
|
||||
@ -316,22 +315,27 @@ function OpenCodeProviderFields({
|
||||
}
|
||||
setFieldErrors((prev) => ({ ...prev, providerId: undefined }));
|
||||
}}
|
||||
className={cn(
|
||||
"h-9 w-full rounded-md border bg-raised px-3 text-sm text-content outline-none",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
fieldErrors.providerId ? "border-danger" : "border-border",
|
||||
)}
|
||||
>
|
||||
<option value="" disabled>
|
||||
{catalog.loading ? "Chargement des providers…" : "Choisir un provider…"}
|
||||
</option>
|
||||
{filteredProviders.map((p) => (
|
||||
<option key={p.providerId} value={p.providerId}>
|
||||
{p.displayName}
|
||||
</option>
|
||||
))}
|
||||
<option value={CUSTOM_PROVIDER_VALUE}>Autre / personnalisé…</option>
|
||||
</select>
|
||||
className="w-full"
|
||||
size="md"
|
||||
invalid={Boolean(fieldErrors.providerId)}
|
||||
options={[
|
||||
{
|
||||
value: "",
|
||||
label: catalog.loading
|
||||
? "Chargement des providers…"
|
||||
: "Choisir un provider…",
|
||||
disabled: true,
|
||||
},
|
||||
...filteredProviders.map((p) => ({
|
||||
value: p.providerId,
|
||||
label: p.displayName,
|
||||
})),
|
||||
{
|
||||
value: CUSTOM_PROVIDER_VALUE,
|
||||
label: "Autre / personnalisé…",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{fieldErrors.providerId && (
|
||||
<small className="text-xs text-danger">{fieldErrors.providerId}</small>
|
||||
)}
|
||||
@ -339,29 +343,26 @@ function OpenCodeProviderFields({
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>Modèle</Caption>
|
||||
<select
|
||||
<SmallDropdown
|
||||
aria-label={`${profile.name} model`}
|
||||
value={model}
|
||||
disabled={providerId.length === 0}
|
||||
onChange={(e) => {
|
||||
setModel(e.target.value);
|
||||
onChange={(next) => {
|
||||
setModel(next);
|
||||
setFieldErrors((prev) => ({ ...prev, model: undefined }));
|
||||
}}
|
||||
className={cn(
|
||||
"h-9 w-full rounded-md border bg-raised px-3 text-sm text-content outline-none",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
fieldErrors.model ? "border-danger" : "border-border",
|
||||
)}
|
||||
>
|
||||
<option value="" disabled>
|
||||
{providerId.length === 0 ? "—" : "Choisir un modèle…"}
|
||||
</option>
|
||||
{models.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{m}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
className="w-full"
|
||||
size="md"
|
||||
invalid={Boolean(fieldErrors.model)}
|
||||
options={[
|
||||
{
|
||||
value: "",
|
||||
label: providerId.length === 0 ? "—" : "Choisir un modèle…",
|
||||
disabled: true,
|
||||
},
|
||||
...models.map((m) => ({ value: m, label: m })),
|
||||
]}
|
||||
/>
|
||||
{fieldErrors.model && (
|
||||
<small className="text-xs text-danger">{fieldErrors.model}</small>
|
||||
)}
|
||||
|
||||
@ -22,6 +22,11 @@ function renderSettings(
|
||||
};
|
||||
}
|
||||
|
||||
function chooseDropdownOption(label: string | RegExp, optionName: string | RegExp) {
|
||||
fireEvent.click(screen.getByLabelText(label));
|
||||
fireEvent.click(screen.getByRole("option", { name: optionName }));
|
||||
}
|
||||
|
||||
async function waitReady() {
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
@ -191,12 +196,17 @@ describe("ProfilesSettings", () => {
|
||||
expect(screen.getByText("Local A")).toBeTruthy();
|
||||
|
||||
await createProfile();
|
||||
const select = await screen.findByLabelText(
|
||||
await screen.findByLabelText(
|
||||
"OpenCode + llama.cpp copy local model server",
|
||||
);
|
||||
fireEvent.change(select, { target: { value: server.id } });
|
||||
chooseDropdownOption(
|
||||
"OpenCode + llama.cpp copy local model server",
|
||||
/Local A/,
|
||||
);
|
||||
|
||||
const profileRow = select.closest("li");
|
||||
const profileRow = screen
|
||||
.getByLabelText("OpenCode + llama.cpp copy local model server")
|
||||
.closest("li");
|
||||
expect(profileRow).not.toBeNull();
|
||||
fireEvent.click(
|
||||
within(profileRow as HTMLElement).getByRole("button", {
|
||||
|
||||
Reference in New Issue
Block a user