mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-21 22:52:19 +03:00
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:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user