feat(combos): add select all / unselect all in Browse Catalog (#8526)

* 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 <diegosouza.pw@gmail.com>
This commit is contained in:
Joshim Uddin
2026-07-26 21:10:44 +06:00
committed by GitHub
parent 5552650a4f
commit 63341f2aed
9 changed files with 717 additions and 15 deletions

View File

@@ -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<string, unknown>) =>
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, unknown>) => string)(key, values)
: fallback;
const [searchQuery, setSearchQuery] = useState("");
const [combos, setCombos] = useState<any[]>([]);
const [providerNodes, setProviderNodes] = useState<any[]>([]);
@@ -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({
</div>
</div>
<label className="flex items-center gap-1.5 mt-1.5 text-xs text-text-muted cursor-pointer">
<input
type="checkbox"
checked={showConfiguredOnly}
onChange={(e) => setShowConfiguredOnly(e.target.checked)}
className="rounded border-border"
/>
{t("showConfiguredOnly")}
</label>
<div className="mt-1.5 mb-2 flex items-center justify-between gap-2">
<label className="flex items-center gap-1.5 text-xs text-text-muted cursor-pointer min-w-0">
<input
type="checkbox"
checked={showConfiguredOnly}
onChange={(e) => setShowConfiguredOnly(e.target.checked)}
className="rounded border-border"
/>
<span className="truncate">{t("showConfiguredOnly")}</span>
</label>
{showSelectAllToggle && (
<button
type="button"
onClick={handleToggleSelectAllVisible}
data-testid="model-select-toggle-all-visible"
className="shrink-0 px-2 py-1 text-xs font-medium rounded border border-border bg-surface text-text-main hover:border-primary/50 hover:bg-primary/5 transition-colors"
>
{allVisibleSelected
? labelOrFallback("unselectAll", "Unselect all")
: labelOrFallback("selectAll", "Select all")}
<span className="ml-1 text-[10px] text-text-muted font-normal">
({visibleModels.length})
</span>
</button>
)}
</div>
{/* Models grouped by provider - compact */}
<div className="max-h-[300px] overflow-y-auto space-y-3">
<div className="max-h-[300px] overflow-y-auto space-y-3 isolate">
{/* Combos section - always first */}
{showCombos && filteredCombos.length > 0 && (
<div>
<div className="flex items-center gap-1.5 mb-1.5 sticky top-0 bg-surface py-0.5">
<div className="flex items-center gap-1.5 mb-1.5 sticky top-0 z-10 bg-surface py-1">
<span className="material-symbols-outlined text-primary text-[14px]">layers</span>
<span className="text-xs font-medium text-primary">{t("combos")}</span>
<span className="text-[10px] text-text-muted">({filteredCombos.length})</span>
@@ -590,9 +686,12 @@ export default function ModelSelectModal({
{/* Provider models */}
{Object.entries(connectionFilteredGroups).map(([providerId, group]: [string, any]) => (
<div key={providerId}>
{/* Provider header */}
<div className="flex items-center gap-1.5 mb-1.5 sticky top-0 bg-surface py-0.5">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: group.color }} />
{/* Provider header — z-10 + opaque bg so scrolling chips don't bleed through */}
<div className="flex items-center gap-1.5 mb-1.5 sticky top-0 z-10 bg-surface py-1">
<div
className="w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: group.color }}
/>
<span className="text-xs font-medium text-primary">{group.name}</span>
<span className="text-[10px] text-text-muted">({group.models.length})</span>
</div>

View File

@@ -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;
}