From 63341f2aedd93e65a071c876e92079dcbe9e1b01 Mon Sep 17 00:00:00 2001 From: Joshim Uddin <70097642+JoshimOfficial@users.noreply.github.com> Date: Sun, 26 Jul 2026 21:10:44 +0600 Subject: [PATCH] feat(combos): add select all / unselect all in Browse Catalog (#8526) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(combos): add select all / unselect all in Browse Catalog * fix(dashboard): guard combo Select all + test the real batch handlers (#8526) Select all had no cap — with "Show configured only" off, or a large provider catalog, one click could add hundreds of models to a combo. ModelSelectModal now confirms above SELECT_ALL_CONFIRM_THRESHOLD (20) before batch-adding, matching the native confirm() pattern already used for bulk/destructive actions elsewhere in the dashboard. Also extracts ComboFormModal's handleAddModels/handleDeselectModels batching logic into computeBatchAddModelSteps/computeBatchDeselectModelSteps (src/lib/combos/builderDraft.ts) so unit tests exercise the real implementation instead of a hand-maintained mirror that could drift from the component and stay green while production code broke. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Co-authored-by: Diego Rodrigues de Sa e Souza --- src/app/(dashboard)/dashboard/combos/page.tsx | 24 ++ src/i18n/messages/en.json | 4 + src/lib/combos/builderDraft.ts | 102 ++++++++ src/shared/components/ModelSelectModal.tsx | 129 ++++++++-- .../components/modelSelectModalHelpers.ts | 20 ++ .../unit/combo-select-all-batch-8526.test.ts | 100 ++++++++ ...-select-all-confirm-threshold-8526.test.ts | 33 +++ .../combo-form-select-all-handlers.test.tsx | 89 +++++++ .../ui/model-select-modal-select-all.test.tsx | 231 ++++++++++++++++++ 9 files changed, 717 insertions(+), 15 deletions(-) create mode 100644 tests/unit/combo-select-all-batch-8526.test.ts create mode 100644 tests/unit/model-select-all-confirm-threshold-8526.test.ts create mode 100644 tests/unit/ui/combo-form-select-all-handlers.test.tsx create mode 100644 tests/unit/ui/model-select-modal-select-all.test.tsx diff --git a/src/app/(dashboard)/dashboard/combos/page.tsx b/src/app/(dashboard)/dashboard/combos/page.tsx index b8ec40407f..9433fb4ab8 100644 --- a/src/app/(dashboard)/dashboard/combos/page.tsx +++ b/src/app/(dashboard)/dashboard/combos/page.tsx @@ -28,6 +28,8 @@ import { buildManualComboModelStep, buildPrecisionComboModelStep, canAccessComboBuilderStage, + computeBatchAddModelSteps, + computeBatchDeselectModelSteps, findNextSuggestedConnectionId, getComboBuilderStageChecks, getComboBuilderStages, @@ -2530,6 +2532,26 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo setBuilderError(""); }; + // Batch add for ModelSelectModal "Select all" — delegates to the pure + // computeBatchAddModelSteps (src/lib/combos/builderDraft.ts) which applies + // every candidate against a growing list in one pass, otherwise N× onSelect + // would each close over the same stale `models` snapshot and keep only the + // last entry. Extracted so tests exercise this real implementation instead + // of a hand-maintained mirror (#8526). + const handleAddModels = (selected) => { + const { next, addedAny } = computeBatchAddModelSteps(models, selected, builderProviders); + if (!addedAny) return; + setModels(next); + setBuilderError(""); + }; + + const handleDeselectModels = (toRemove) => { + const next = computeBatchDeselectModelSteps(models, toRemove); + if (next === models) return; + setModels(next); + setBuilderError(""); + }; + const handleWeightChange = (index, weight) => { const newModels = [...models]; newModels[index] = { @@ -4610,6 +4632,8 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo onClose={() => setShowModelSelect(false)} onSelect={handleAddModel} onDeselect={handleDeselectModel} + onSelectMany={handleAddModels} + onDeselectMany={handleDeselectModels} activeProviders={activeProviders} modelAliases={modelAliases} title={t("addModelToCombo")} diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 50fa6e04a9..d271c91872 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -168,6 +168,10 @@ "noModelsFound": "No models found", "clear": "Clear", "done": "Done", + "selectAll": "Select all", + "unselectAll": "Unselect all", + "visibleModels": "visible", + "selectAllConfirm": "Add {count} models to this combo?", "errorOccurred": "Error Occurred", "comboDeleted": "Combo Deleted", "hide": "Hide", diff --git a/src/lib/combos/builderDraft.ts b/src/lib/combos/builderDraft.ts index 18e6a09e31..9fd9221820 100644 --- a/src/lib/combos/builderDraft.ts +++ b/src/lib/combos/builderDraft.ts @@ -267,3 +267,105 @@ export function getPreviousComboBuilderStage( if (stageIndex <= 0) return "basics"; return stages[stageIndex - 1]; } + +export type ComboBuilderModelCandidate = { + value?: unknown; + providerId?: unknown; +}; + +export type ComboBuilderDraftModelStep = { + model: string; + providerId?: string; + weight: number; +}; + +/** + * Resolve the providerId for one "Select all" candidate: an exact/alias/prefix + * match in `builderProviders` wins, falling back to whatever provider prefix + * the qualified model string itself carries. Split out of + * `computeBatchAddModelSteps` to keep that loop under the complexity budget. + */ +function resolveBatchCandidateProviderId( + model: ComboBuilderModelCandidate, + parsedModel: { providerId: string; modelId: string } | null, + builderProviders: ComboBuilderProviderIdentity[] +): string | null { + return ( + resolveComboBuilderProviderId(model?.providerId, builderProviders) || + resolveComboBuilderProviderId(parsedModel?.providerId, builderProviders) || + (typeof model?.providerId === "string" && model.providerId.trim()) || + parsedModel?.providerId || + null + ); +} + +/** + * Batch-add handler for ModelSelectModal's "Select all" (`onSelectMany`) in + * the combo builder — must apply every candidate against a growing list in + * ONE pass. Looping the single-add handler N times would have each call + * close over the same stale `models` snapshot and only the last selected + * model would survive (#8526). + * + * This is the real implementation `ComboFormModal::handleAddModels` in + * `page.tsx` delegates to, so unit tests exercise actual production logic + * instead of a hand-maintained copy that can drift from the component. + */ +export function computeBatchAddModelSteps( + models: ComboBuilderDraftModelStep[], + selected: ComboBuilderModelCandidate[], + builderProviders: ComboBuilderProviderIdentity[] = [] +): { next: ComboBuilderDraftModelStep[]; addedAny: boolean } { + if (!Array.isArray(selected) || selected.length === 0) { + return { next: models, addedAny: false }; + } + const next = [...models]; + let addedAny = false; + for (const model of selected) { + const qualifiedModel = typeof model?.value === "string" ? model.value : ""; + if (!qualifiedModel) continue; + const parsedModel = parseQualifiedModel(qualifiedModel); + const resolvedProviderId = resolveBatchCandidateProviderId( + model, + parsedModel, + builderProviders + ); + const nextEntry: ComboBuilderDraftModelStep = { + model: qualifiedModel, + ...(resolvedProviderId ? { providerId: resolvedProviderId } : {}), + weight: 0, + }; + if (hasExactModelStepDuplicate(next, nextEntry)) continue; + next.push(nextEntry); + addedAny = true; + } + return { next, addedAny }; +} + +/** + * Batch-remove handler for ModelSelectModal's "Unselect all" (`onDeselectMany`) + * in the combo builder — same stale-snapshot reasoning as + * `computeBatchAddModelSteps` above. Accepts either `{ value }` candidate + * objects (from the modal) or raw qualified-model strings. + * + * Returns the same `models` reference (no-op) when there is nothing to + * remove, so callers can skip the `setModels` call. + */ +export function computeBatchDeselectModelSteps( + models: ComboBuilderDraftModelStep[], + toRemove: Array<{ value?: unknown } | string> +): ComboBuilderDraftModelStep[] { + if (!Array.isArray(toRemove) || toRemove.length === 0) return models; + const values = new Set( + toRemove + .map((model) => + typeof (model as { value?: unknown })?.value === "string" + ? (model as { value: string }).value + : typeof model === "string" + ? model + : "" + ) + .filter(Boolean) + ); + if (values.size === 0) return models; + return models.filter((m) => !values.has(m.model)); +} diff --git a/src/shared/components/ModelSelectModal.tsx b/src/shared/components/ModelSelectModal.tsx index 6d4e3f1b61..ed656e537a 100644 --- a/src/shared/components/ModelSelectModal.tsx +++ b/src/shared/components/ModelSelectModal.tsx @@ -3,7 +3,11 @@ import { useState, useMemo, useEffect } from "react"; import { useTranslations } from "next-intl"; import Modal from "./Modal"; -import { buildPassthroughAliasModels, buildNodeAliasModels } from "./modelSelectModalHelpers"; +import { + buildPassthroughAliasModels, + buildNodeAliasModels, + shouldConfirmSelectAll, +} from "./modelSelectModalHelpers"; import { getModelsByProviderId, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models"; import { getCompatibleFallbackModels } from "@/lib/providers/managedAvailableModels"; import { @@ -38,6 +42,16 @@ type ModelSelectModalProps = { * decolua/9router#889 (Fajar Hidayat). */ onDeselect?: (model: unknown) => void; + /** + * Batch add for "Select all" — callers that keep the modal open (combo + * builder) must use this instead of looping `onSelect`, because each + * single-add handler closes over the same models snapshot. + */ + onSelectMany?: (models: unknown[]) => void; + /** + * Batch remove for "Unselect all" — same stale-state reason as onSelectMany. + */ + onDeselectMany?: (models: unknown[]) => void; selectedModel?: string; selectedModels?: string[]; activeProviders?: Array<{ @@ -72,6 +86,8 @@ export default function ModelSelectModal({ onClose, onSelect, onDeselect, + onSelectMany, + onDeselectMany, selectedModel, selectedModels = [], activeProviders = [], @@ -85,6 +101,11 @@ export default function ModelSelectModal({ }: ModelSelectModalProps) { const t = useTranslations("common"); const resolvedTitle = title ?? t("selectModel"); + const labelOrFallback = (key: string, fallback: string, values?: Record) => + typeof (t as { has?: (k: string) => boolean }).has === "function" && + (t as { has: (k: string) => boolean }).has(key) + ? (t as unknown as (k: string, v?: Record) => string)(key, values) + : fallback; const [searchQuery, setSearchQuery] = useState(""); const [combos, setCombos] = useState([]); const [providerNodes, setProviderNodes] = useState([]); @@ -460,6 +481,63 @@ export default function ModelSelectModal({ return result; }, [filteredGroups, showConfiguredOnly, activeProviders]); + // Flat list of currently visible provider models (respects search + configured-only). + // Used by Select all / Unselect all — does not include the Combos section. + const visibleModels = useMemo(() => { + const models: any[] = []; + Object.values(connectionFilteredGroups).forEach((group: any) => { + if (Array.isArray(group?.models)) { + models.push(...group.models); + } + }); + return models; + }, [connectionFilteredGroups]); + + const addedModelValueSet = useMemo(() => new Set(addedModelValues), [addedModelValues]); + + const allVisibleSelected = + visibleModels.length > 0 && + visibleModels.every( + (model) => typeof model?.value === "string" && addedModelValueSet.has(model.value) + ); + + const showSelectAllToggle = + keepOpenOnSelect && + !multiSelect && + typeof onSelectMany === "function" && + typeof onDeselectMany === "function" && + visibleModels.length > 0; + + const handleToggleSelectAllVisible = () => { + if (!showSelectAllToggle) return; + if (allVisibleSelected) { + const toRemove = visibleModels.filter( + (model) => typeof model?.value === "string" && addedModelValueSet.has(model.value) + ); + if (toRemove.length > 0) onDeselectMany!(toRemove); + return; + } + const toAdd = visibleModels.filter( + (model) => typeof model?.value === "string" && !addedModelValueSet.has(model.value) + ); + if (toAdd.length === 0) return; + // Guard against a single click adding hundreds of models (e.g. with + // "Show configured only" off) — see modelSelectModalHelpers.ts (#8526). + if ( + shouldConfirmSelectAll(toAdd.length) && + !confirm( + labelOrFallback( + "selectAllConfirm", + `Add ${toAdd.length} models to this combo?`, + { count: toAdd.length } + ) + ) + ) { + return; + } + onSelectMany!(toAdd); + }; + const resolvedSelectedModels = multiSelect ? selectedModels : selectedModel @@ -541,22 +619,40 @@ export default function ModelSelectModal({ - +
+ + + {showSelectAllToggle && ( + + )} +
{/* Models grouped by provider - compact */} -
+
{/* Combos section - always first */} {showCombos && filteredCombos.length > 0 && (
-
+
layers {t("combos")} ({filteredCombos.length}) @@ -590,9 +686,12 @@ export default function ModelSelectModal({ {/* Provider models */} {Object.entries(connectionFilteredGroups).map(([providerId, group]: [string, any]) => (
- {/* Provider header */} -
-
+ {/* Provider header — z-10 + opaque bg so scrolling chips don't bleed through */} +
+
{group.name} ({group.models.length})
diff --git a/src/shared/components/modelSelectModalHelpers.ts b/src/shared/components/modelSelectModalHelpers.ts index ed40e152dc..5ce8976e97 100644 --- a/src/shared/components/modelSelectModalHelpers.ts +++ b/src/shared/components/modelSelectModalHelpers.ts @@ -73,3 +73,23 @@ export function buildNodeAliasModels( source: "alias" as const, })); } + +/** + * "Select all" adds every currently-visible model to the combo in one click, + * with no cap — turning off "Show configured only" (or just having a large + * provider catalog) can put hundreds of candidates behind a single click. + * Above this threshold the caller must confirm before batch-adding (#8526). + * + * A native `confirm()` — not a bespoke modal — matches the existing bulk / + * destructive-action pattern already used in this codebase (e.g. + * `ReasoningRoutingRules.tsx::deleteConfirm`, `combos/page.tsx::deleteConfirm`), + * so no new UI primitive is needed for this one interaction. + */ +export const SELECT_ALL_CONFIRM_THRESHOLD = 20; + +export function shouldConfirmSelectAll( + candidateCount: number, + threshold: number = SELECT_ALL_CONFIRM_THRESHOLD +): boolean { + return Number.isFinite(candidateCount) && candidateCount > threshold; +} diff --git a/tests/unit/combo-select-all-batch-8526.test.ts b/tests/unit/combo-select-all-batch-8526.test.ts new file mode 100644 index 0000000000..5a2b3b7be7 --- /dev/null +++ b/tests/unit/combo-select-all-batch-8526.test.ts @@ -0,0 +1,100 @@ +// Regression guard for #8526 (combo Browse Catalog "Select all"/"Unselect +// all"). This is the BLOCKING copy — tests/unit/*.test.ts is collected by +// `npm run test:unit` (node:test), unlike tests/unit/ui/*.test.tsx which is +// only collected by the advisory `test:vitest:ui` +// (see CONTRIBUTING.md "Both test runners must pass"). +// +// It exercises the exact functions `ComboFormModal::handleAddModels` / +// `handleDeselectModels` (src/app/(dashboard)/dashboard/combos/page.tsx) +// delegate to — not a hand-maintained mirror of the batching logic — so a +// broken extraction fails this suite, not just an advisory one. +import test from "node:test"; +import assert from "node:assert/strict"; + +const builderDraft = await import("../../src/lib/combos/builderDraft.ts"); + +test("computeBatchAddModelSteps adds every candidate against a growing list in one pass", () => { + const { next, addedAny } = builderDraft.computeBatchAddModelSteps( + [], + [ + { value: "openai/gpt-4o" }, + { value: "openai/gpt-4o-mini" }, + { value: "anthropic/claude-3-5-sonnet" }, + ], + [] + ); + assert.equal(addedAny, true); + assert.deepEqual( + next.map((m) => m.model), + ["openai/gpt-4o", "openai/gpt-4o-mini", "anthropic/claude-3-5-sonnet"] + ); +}); + +test("computeBatchAddModelSteps skips exact provider/model/account duplicates", () => { + const existing = [{ model: "openai/gpt-4o", providerId: "openai", weight: 0 }]; + const { next, addedAny } = builderDraft.computeBatchAddModelSteps( + existing, + [{ value: "openai/gpt-4o", providerId: "openai" }, { value: "openai/gpt-4o-mini" }], + [] + ); + assert.equal(addedAny, true); + assert.deepEqual( + next.map((m) => m.model), + ["openai/gpt-4o", "openai/gpt-4o-mini"] + ); +}); + +test("computeBatchAddModelSteps resolves providerId via builderProviders alias/prefix", () => { + const builderProviders = [{ providerId: "openai", alias: "oa", prefix: "gpt" }]; + const { next } = builderDraft.computeBatchAddModelSteps( + [], + [{ value: "gpt/gpt-4o", providerId: "gpt" }], + builderProviders + ); + assert.equal(next[0].providerId, "openai"); +}); + +test("computeBatchAddModelSteps ignores empty/missing values and is a no-op with nothing selected", () => { + const { next, addedAny } = builderDraft.computeBatchAddModelSteps( + [], + [{ value: "" }, {}, { value: "openai/gpt-4o" }], + [] + ); + assert.equal(addedAny, true); + // No matching entry in builderProviders, so providerId falls back to the + // provider prefix parsed out of the qualified model string. + assert.deepEqual(next, [{ model: "openai/gpt-4o", providerId: "openai", weight: 0 }]); + + const existing = [{ model: "openai/gpt-4o", weight: 0 }]; + const noop = builderDraft.computeBatchAddModelSteps(existing, [], []); + assert.equal(noop.addedAny, false); + assert.equal(noop.next, existing); +}); + +test("computeBatchDeselectModelSteps removes every matching qualified model in one pass", () => { + const models = [ + { model: "openai/gpt-4o", weight: 0 }, + { model: "openai/gpt-4o-mini", weight: 0 }, + { model: "anthropic/claude-3-5-sonnet", weight: 0 }, + ]; + const next = builderDraft.computeBatchDeselectModelSteps(models, [ + { value: "openai/gpt-4o" }, + { value: "openai/gpt-4o-mini" }, + ]); + assert.deepEqual(next, [{ model: "anthropic/claude-3-5-sonnet", weight: 0 }]); +}); + +test("computeBatchDeselectModelSteps accepts raw string identifiers", () => { + const models = [ + { model: "openai/gpt-4o", weight: 0 }, + { model: "openai/gpt-4o-mini", weight: 0 }, + ]; + const next = builderDraft.computeBatchDeselectModelSteps(models, ["openai/gpt-4o"]); + assert.deepEqual(next, [{ model: "openai/gpt-4o-mini", weight: 0 }]); +}); + +test("computeBatchDeselectModelSteps is a no-op (same reference) when there is nothing to remove", () => { + const models = [{ model: "openai/gpt-4o", weight: 0 }]; + assert.equal(builderDraft.computeBatchDeselectModelSteps(models, []), models); + assert.equal(builderDraft.computeBatchDeselectModelSteps(models, [{ value: "" }]), models); +}); diff --git a/tests/unit/model-select-all-confirm-threshold-8526.test.ts b/tests/unit/model-select-all-confirm-threshold-8526.test.ts new file mode 100644 index 0000000000..fd8772787e --- /dev/null +++ b/tests/unit/model-select-all-confirm-threshold-8526.test.ts @@ -0,0 +1,33 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + SELECT_ALL_CONFIRM_THRESHOLD, + shouldConfirmSelectAll, +} from "../../src/shared/components/modelSelectModalHelpers.ts"; + +// Regression guard for #8526: ModelSelectModal's "Select all" (Browse +// Catalog, combo builder) had no cap — turning off "Show configured only", +// or just having a large provider catalog, could add hundreds of models to a +// combo in one click. Above SELECT_ALL_CONFIRM_THRESHOLD the caller must +// confirm before batch-adding. + +test("shouldConfirmSelectAll: does not require confirmation at or below the threshold", () => { + assert.equal(shouldConfirmSelectAll(0), false); + assert.equal(shouldConfirmSelectAll(1), false); + assert.equal(shouldConfirmSelectAll(SELECT_ALL_CONFIRM_THRESHOLD), false); +}); + +test("shouldConfirmSelectAll: requires confirmation above the threshold", () => { + assert.equal(shouldConfirmSelectAll(SELECT_ALL_CONFIRM_THRESHOLD + 1), true); + assert.equal(shouldConfirmSelectAll(500), true); +}); + +test("shouldConfirmSelectAll: respects a caller-supplied threshold override", () => { + assert.equal(shouldConfirmSelectAll(5, 10), false); + assert.equal(shouldConfirmSelectAll(11, 10), true); +}); + +test("shouldConfirmSelectAll: tolerates non-finite counts without throwing", () => { + assert.equal(shouldConfirmSelectAll(Number.NaN), false); + assert.equal(shouldConfirmSelectAll(Number.POSITIVE_INFINITY), false); +}); diff --git a/tests/unit/ui/combo-form-select-all-handlers.test.tsx b/tests/unit/ui/combo-form-select-all-handlers.test.tsx new file mode 100644 index 0000000000..ad80844e3c --- /dev/null +++ b/tests/unit/ui/combo-form-select-all-handlers.test.tsx @@ -0,0 +1,89 @@ +// Exercises the REAL Select all / Unselect all batch handlers ComboFormModal +// (src/app/(dashboard)/dashboard/combos/page.tsx::handleAddModels / +// handleDeselectModels) delegates to. Previously this test re-implemented the +// batch logic by hand as a parallel copy — that copy could stay green while +// the real component logic broke (#8526). The regression guard that actually +// blocks CI is the node:test copy of this file at +// tests/unit/combo-select-all-batch-8526.test.ts (this file's location, +// tests/unit/ui/*.test.tsx, is only collected by the advisory +// `test:vitest:ui` — see CONTRIBUTING.md "Both test runners must pass"). +import { describe, expect, it } from "vitest"; +import { + computeBatchAddModelSteps, + computeBatchDeselectModelSteps, +} from "@/lib/combos/builderDraft"; + +describe("ComboFormModal Select all / Unselect all handlers (real implementation)", () => { + it("adds every candidate in one pass (no stale single-add overwrite)", () => { + const { next, addedAny } = computeBatchAddModelSteps( + [], + [ + { value: "openai/gpt-4o" }, + { value: "openai/gpt-4o-mini" }, + { value: "anthropic/claude-3-5-sonnet" }, + ], + [] + ); + expect(addedAny).toBe(true); + expect(next.map((m) => m.model)).toEqual([ + "openai/gpt-4o", + "openai/gpt-4o-mini", + "anthropic/claude-3-5-sonnet", + ]); + }); + + it("skips models already present (exact duplicate)", () => { + const existing = [{ model: "openai/gpt-4o", providerId: "openai", weight: 0 }]; + const { next } = computeBatchAddModelSteps( + existing, + [{ value: "openai/gpt-4o", providerId: "openai" }, { value: "openai/gpt-4o-mini" }], + [] + ); + expect(next.map((m) => m.model)).toEqual(["openai/gpt-4o", "openai/gpt-4o-mini"]); + }); + + it("ignores empty / missing values", () => { + const { next, addedAny } = computeBatchAddModelSteps( + [], + [{ value: "" }, {}, { value: "openai/gpt-4o" }], + [] + ); + expect(addedAny).toBe(true); + expect(next).toEqual([{ model: "openai/gpt-4o", providerId: "openai", weight: 0 }]); + }); + + it("is a no-op when nothing is selected", () => { + const existing = [{ model: "openai/gpt-4o", weight: 0 }]; + const { next, addedAny } = computeBatchAddModelSteps(existing, [], []); + expect(addedAny).toBe(false); + expect(next).toBe(existing); + }); + + it("removes every matching qualified model in one pass", () => { + const models = [ + { model: "openai/gpt-4o", weight: 0 }, + { model: "openai/gpt-4o-mini", weight: 0 }, + { model: "anthropic/claude-3-5-sonnet", weight: 0 }, + ]; + const next = computeBatchDeselectModelSteps(models, [ + { value: "openai/gpt-4o" }, + { value: "openai/gpt-4o-mini" }, + ]); + expect(next).toEqual([{ model: "anthropic/claude-3-5-sonnet", weight: 0 }]); + }); + + it("accepts raw string identifiers in the deselect batch", () => { + const models = [ + { model: "openai/gpt-4o", weight: 0 }, + { model: "openai/gpt-4o-mini", weight: 0 }, + ]; + const next = computeBatchDeselectModelSteps(models, ["openai/gpt-4o"]); + expect(next).toEqual([{ model: "openai/gpt-4o-mini", weight: 0 }]); + }); + + it("is a no-op (same reference) when nothing to remove", () => { + const models = [{ model: "openai/gpt-4o", weight: 0 }]; + expect(computeBatchDeselectModelSteps(models, [])).toBe(models); + expect(computeBatchDeselectModelSteps(models, [{ value: "" }])).toBe(models); + }); +}); diff --git a/tests/unit/ui/model-select-modal-select-all.test.tsx b/tests/unit/ui/model-select-modal-select-all.test.tsx new file mode 100644 index 0000000000..11d215e0ee --- /dev/null +++ b/tests/unit/ui/model-select-modal-select-all.test.tsx @@ -0,0 +1,231 @@ +// @vitest-environment jsdom +// +// Select all / Unselect all for currently visible models in ModelSelectModal. +// Used by combo Create/Edit "Browse Catalog" (keepOpenOnSelect + batch callbacks). +import React from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("next-intl", () => ({ + useTranslations: () => { + const t = (key: string) => key; + t.has = () => false; + return t; + }, +})); + +const { default: ModelSelectModal } = await import("@/shared/components/ModelSelectModal"); + +const containers: HTMLElement[] = []; + +async function renderModal(props: Partial> = {}) { + const container = document.createElement("div"); + document.body.appendChild(container); + containers.push(container); + + const root = createRoot(container); + await act(async () => { + root.render( + {}} + onSelect={() => {}} + showCombos={false} + activeProviders={[{ provider: "openai" }]} + keepOpenOnSelect + {...props} + /> + ); + }); + + await act(async () => {}); + return { container, root }; +} + +beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + // These tests exercise selection bookkeeping, not the confirm-above-threshold + // UX (see modelSelectModalHelpers.ts::shouldConfirmSelectAll) — stub confirm + // to always accept so assertions don't depend on how many models the fixture + // catalog happens to expose. + vi.stubGlobal("confirm", vi.fn(() => true)); + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => { + if (url === "/api/provider-nodes") { + return { ok: true, json: async () => ({ nodes: [] }) }; + } + if (url === "/api/provider-models") { + return { ok: true, json: async () => ({ models: {} }) }; + } + if (url === "/api/combos") { + return { ok: true, json: async () => ({ combos: [] }) }; + } + return { ok: true, json: async () => ({}) }; + }) + ); +}); + +afterEach(() => { + while (containers.length > 0) { + containers.pop()?.remove(); + } + document.body.innerHTML = ""; + vi.unstubAllGlobals(); +}); + +describe("ModelSelectModal — Select all / Unselect all visible models", () => { + it("does not render the toggle without batch callbacks", async () => { + const { container } = await renderModal(); + expect(container.querySelector('[data-testid="model-select-toggle-all-visible"]')).toBeNull(); + }); + + it("renders Select all when keepOpenOnSelect + batch callbacks are set", async () => { + const { container } = await renderModal({ + onSelectMany: vi.fn(), + onDeselectMany: vi.fn(), + addedModelValues: [], + }); + + const toggle = container.querySelector( + '[data-testid="model-select-toggle-all-visible"]' + ) as HTMLButtonElement | null; + expect(toggle, "expected Select all toggle").not.toBeNull(); + expect(toggle!.textContent).toMatch(/Select all/i); + }); + + it("calls onSelectMany with every visible model that is not yet added", async () => { + const onSelectMany = vi.fn(); + const onDeselectMany = vi.fn(); + + const { container } = await renderModal({ + onSelectMany, + onDeselectMany, + addedModelValues: [], + }); + + const toggle = container.querySelector( + '[data-testid="model-select-toggle-all-visible"]' + ) as HTMLButtonElement; + expect(toggle).not.toBeNull(); + + await act(async () => { + toggle.click(); + }); + + expect(onSelectMany).toHaveBeenCalledTimes(1); + expect(onDeselectMany).not.toHaveBeenCalled(); + const selected = onSelectMany.mock.calls[0][0] as Array<{ value: string }>; + expect(Array.isArray(selected)).toBe(true); + expect(selected.length).toBeGreaterThan(1); + expect(selected.every((m) => typeof m.value === "string" && m.value.length > 0)).toBe(true); + }); + + it("shows Unselect all and calls onDeselectMany when every visible model is already added", async () => { + const probeSelect = vi.fn(); + const probe = await renderModal({ + onSelectMany: probeSelect, + onDeselectMany: vi.fn(), + addedModelValues: [], + }); + const probeToggle = probe.container.querySelector( + '[data-testid="model-select-toggle-all-visible"]' + ) as HTMLButtonElement; + await act(async () => { + probeToggle.click(); + }); + const allValues = (probeSelect.mock.calls[0][0] as Array<{ value: string }>).map( + (m) => m.value + ); + expect(allValues.length).toBeGreaterThan(1); + + const onSelectMany = vi.fn(); + const onDeselectMany = vi.fn(); + const { container } = await renderModal({ + onSelectMany, + onDeselectMany, + addedModelValues: allValues, + }); + + const toggle = container.querySelector( + '[data-testid="model-select-toggle-all-visible"]' + ) as HTMLButtonElement; + expect(toggle.textContent).toMatch(/Unselect all/i); + + await act(async () => { + toggle.click(); + }); + + expect(onDeselectMany).toHaveBeenCalledTimes(1); + expect(onSelectMany).not.toHaveBeenCalled(); + const removed = onDeselectMany.mock.calls[0][0] as Array<{ value: string }>; + expect(removed.map((m) => m.value).sort()).toEqual([...allValues].sort()); + }); + + it("Select all only adds models that are not already in addedModelValues", async () => { + const probeSelect = vi.fn(); + const probe = await renderModal({ + onSelectMany: probeSelect, + onDeselectMany: vi.fn(), + addedModelValues: [], + }); + const probeToggle = probe.container.querySelector( + '[data-testid="model-select-toggle-all-visible"]' + ) as HTMLButtonElement; + await act(async () => { + probeToggle.click(); + }); + const allValues = (probeSelect.mock.calls[0][0] as Array<{ value: string }>).map( + (m) => m.value + ); + const alreadyAdded = [allValues[0]]; + + const onSelectMany = vi.fn(); + const { container } = await renderModal({ + onSelectMany, + onDeselectMany: vi.fn(), + addedModelValues: alreadyAdded, + }); + + const toggle = container.querySelector( + '[data-testid="model-select-toggle-all-visible"]' + ) as HTMLButtonElement; + await act(async () => { + toggle.click(); + }); + + const selected = onSelectMany.mock.calls[0][0] as Array<{ value: string }>; + expect(selected.some((m) => m.value === alreadyAdded[0])).toBe(false); + expect(selected.length).toBe(allValues.length - 1); + }); + + it("asks for confirmation before Select all above the threshold, and honors decline", async () => { + const confirmSpy = vi.fn(() => false); + vi.stubGlobal("confirm", confirmSpy); + + const onSelectMany = vi.fn(); + // openai (20 models) + anthropic (10 models) = 30 candidates, comfortably + // above SELECT_ALL_CONFIRM_THRESHOLD (20) — see modelSelectModalHelpers.ts. + const { container } = await renderModal({ + activeProviders: [{ provider: "openai" }, { provider: "anthropic" }], + onSelectMany, + onDeselectMany: vi.fn(), + addedModelValues: [], + }); + + const toggle = container.querySelector( + '[data-testid="model-select-toggle-all-visible"]' + ) as HTMLButtonElement; + + await act(async () => { + toggle.click(); + }); + + expect(confirmSpy).toHaveBeenCalledTimes(1); + // Declining the confirmation must abort the batch add entirely. + expect(onSelectMany).not.toHaveBeenCalled(); + }); +});