Skip to content

Commit 900d4cc

Browse files
feat: select all option for tool management (#825)
- Adds the option to select/deselect all tools in a group when managing tool - Cleans up the UI in the project menu <img width="2462" height="1098" alt="CleanShot 2025-11-11 at 14 50 37@2x" src="https://github.com/user-attachments/assets/0607839d-2d65-413b-b6e4-a1668a1c6093" /> <img width="2518" height="1272" alt="CleanShot 2025-11-11 at 14 50 42@2x" src="https://github.com/user-attachments/assets/d368902f-07f2-46c1-ab11-6d8139e8d606" /> <img width="470" height="464" alt="CleanShot 2025-11-11 at 14 26 14@2x" src="https://github.com/user-attachments/assets/919bbaf4-7b82-4216-859f-b251b8732b2b" />
1 parent 9c9a9ee commit 900d4cc

File tree

8 files changed

+914
-710
lines changed

8 files changed

+914
-710
lines changed

.changeset/cold-items-smoke.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"dashboard": patch
3+
---
4+
5+
Adds the option to select/deselect all during tool management, for example when adding tools to a toolset

client/dashboard/src/components/nav-menu.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ import {
55
} from "@/components/ui/sidebar";
66
import { cn } from "@/lib/utils";
77
import { AppRoute } from "@/routes";
8-
import { Type } from "./ui/type";
98
import React from "react";
109
import { ProductTierBadge } from "./product-tier-badge";
10+
import { Type } from "./ui/type";
1111

1212
export function NavMenu({
1313
items,

client/dashboard/src/components/project-menu.tsx

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { useSdkClient } from "@/contexts/Sdk.tsx";
88
import { cn } from "@/lib/utils.ts";
99
import { ProjectEntry } from "@gram/client/models/components";
1010
import { Icon, Stack } from "@speakeasy-api/moonshine";
11-
import { ChevronsUpDown, MessageCircle, PlusIcon } from "lucide-react";
11+
import { ChevronsUpDown, PlusIcon } from "lucide-react";
1212
import React from "react";
1313
import { InputDialog } from "./input-dialog.tsx";
1414
import { NavButton } from "./nav-menu.tsx";
@@ -206,19 +206,31 @@ export function ProjectMenu() {
206206
<NavButton
207207
title="Manage members"
208208
href={membershipURL}
209-
Icon={() => <Icon name="users-round" />}
209+
Icon={(props) => (
210+
<Icon
211+
name="users-round"
212+
{...props}
213+
className={cn(props.className, "mr-1")} // Needed to match the styling of the log out button
214+
/>
215+
)}
210216
onClick={() => setOpen(false)}
211217
/>
212218
</SimpleTooltip>
213219
<NavButton
214220
title="Contact us"
215221
href="https://calendly.com/d/crtj-3tk-wpd/demo-with-speakeasy"
216-
Icon={() => <MessageCircle className="h-4 w-4" />}
222+
Icon={(props) => (
223+
<Icon
224+
name="message-circle"
225+
{...props}
226+
className={cn(props.className, "mr-1")} // Needed to match the styling of the log out button
227+
/>
228+
)}
217229
onClick={() => setOpen(false)}
218230
/>
219231
<NavButton
220232
title="Log out"
221-
Icon={() => <Icon name="log-out" />}
233+
Icon={(props) => <Icon name="log-out" {...props} />}
222234
onClick={async () => {
223235
await client.auth.logout();
224236
window.location.href = "/login";

client/dashboard/src/components/tool-list/ToolList.tsx

Lines changed: 146 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
} from "lucide-react";
2020
import { useEffect, useMemo, useState } from "react";
2121
import { ToolVariationBadge } from "../tool-variation-badge";
22+
import { SimpleTooltip } from "../ui/tooltip";
2223
import { Type } from "../ui/type";
2324
import { MethodBadge } from "./MethodBadge";
2425
import { SubtoolsBadge } from "./SubtoolsBadge";
@@ -442,37 +443,73 @@ function ToolGroupHeader({
442443
isExpanded,
443444
onToggle,
444445
isFirstGroup = false,
446+
allSelected,
447+
onSelectAll,
445448
}: {
446449
group: ToolGroup;
447450
isExpanded: boolean;
448451
onToggle: () => void;
449452
isFirstGroup?: boolean;
453+
allSelected: boolean;
454+
onSelectAll: () => void;
450455
}) {
451456
const Icon = getIcon(group.icon);
452457

453458
return (
454-
<button
455-
onClick={onToggle}
456-
aria-expanded={isExpanded}
457-
aria-label={`${isExpanded ? "Collapse" : "Expand"} ${group.title} group`}
459+
<div
458460
className={cn(
459-
"bg-surface-secondary-default flex items-center justify-between pl-4 pr-3 py-4 w-full hover:bg-active transition-colors",
461+
"group/header bg-surface-secondary-default flex items-center justify-between pl-4 pr-3 py-4 w-full",
460462
isExpanded && "border-b border-neutral-softest",
461463
!isFirstGroup && "border-t border-neutral-softest",
462464
)}
463465
>
464-
<div className="flex gap-4 items-center">
465-
<Icon className="size-4 shrink-0" strokeWidth={1.5} />
466+
<button
467+
onClick={onToggle}
468+
aria-expanded={isExpanded}
469+
aria-label={`${isExpanded ? "Collapse" : "Expand"} ${group.title} group`}
470+
className="flex gap-4 items-center hover:opacity-70 transition-opacity"
471+
>
472+
<div className="relative size-4 shrink-0">
473+
<Icon
474+
className={cn(
475+
"size-4 absolute inset-0 transition-opacity",
476+
"group-hover/header:opacity-0",
477+
)}
478+
strokeWidth={1.5}
479+
/>
480+
<SimpleTooltip
481+
tooltip={`${allSelected ? "Deselect" : "Select"} ${group.tools.length} tools`}
482+
>
483+
<Checkbox
484+
checked={allSelected}
485+
onCheckedChange={onSelectAll}
486+
onClick={(e) => {
487+
e.stopPropagation();
488+
}}
489+
className={cn(
490+
"absolute inset-0 transition-opacity opacity-0",
491+
"group-hover/header:opacity-100",
492+
)}
493+
/>
494+
</SimpleTooltip>
495+
</div>
466496
<p className="text-sm leading-6 text-foreground">{group.title}</p>
467-
</div>
468-
<ChevronDown
469-
className={cn(
470-
"size-4 transition-transform",
471-
isExpanded ? "rotate-180" : "rotate-0",
472-
)}
473-
strokeWidth={1.5}
474-
/>
475-
</button>
497+
</button>
498+
<button
499+
onClick={onToggle}
500+
aria-expanded={isExpanded}
501+
aria-label={`${isExpanded ? "Collapse" : "Expand"} ${group.title} group`}
502+
className="hover:opacity-70 transition-opacity"
503+
>
504+
<ChevronDown
505+
className={cn(
506+
"size-4 transition-transform",
507+
isExpanded ? "rotate-180" : "rotate-0",
508+
)}
509+
strokeWidth={1.5}
510+
/>
511+
</button>
512+
</div>
476513
);
477514
}
478515

@@ -741,6 +778,39 @@ export function ToolList({
741778
setSelectedForRemoval(new Set());
742779
};
743780

781+
const handleSelectAllInGroup = (group: ToolGroup) => {
782+
const groupToolIds = group.tools.map(getToolIdentifier);
783+
const currentSelection =
784+
selectionMode === "add" ? selectedSet : selectedForRemoval;
785+
const allSelected = groupToolIds.every((id) => currentSelection.has(id));
786+
787+
if (selectionMode === "add" && onSelectionChange) {
788+
// For selection mode, update parent state
789+
const next = new Set(selectedUrns);
790+
if (allSelected) {
791+
// Deselect all in group
792+
groupToolIds.forEach((id) => next.delete(id));
793+
} else {
794+
// Select all in group
795+
groupToolIds.forEach((id) => next.add(id));
796+
}
797+
onSelectionChange(Array.from(next));
798+
} else {
799+
// For normal mode, update local state
800+
setSelectedForRemoval((prev) => {
801+
const next = new Set(prev);
802+
if (allSelected) {
803+
// Deselect all in group
804+
groupToolIds.forEach((id) => next.delete(id));
805+
} else {
806+
// Select all in group
807+
groupToolIds.forEach((id) => next.add(id));
808+
}
809+
return next;
810+
});
811+
}
812+
};
813+
744814
return (
745815
<div className="relative w-full">
746816
<div
@@ -749,52 +819,66 @@ export function ToolList({
749819
className,
750820
)}
751821
>
752-
{groups.map((group, index) => (
753-
<div key={`${group.type}-${group.title}-${index}`} className="w-full">
754-
<ToolGroupHeader
755-
group={group}
756-
isExpanded={expandedGroups.has(index)}
757-
onToggle={() => toggleGroup(index)}
758-
isFirstGroup={index === 0}
759-
/>
760-
{expandedGroups.has(index) && (
761-
<div className="w-full">
762-
{group.tools.map((tool) => {
763-
const toolId = getToolIdentifier(tool);
764-
const toolIndex = toolIndexMap.get(toolId) ?? -1;
765-
766-
return (
767-
<ToolRow
768-
key={tool.canonicalName}
769-
groupName={group.title}
770-
availableToolUrns={toolset?.tools
771-
?.map((t) => t.toolUrn)
772-
.concat(selectionMode === "add" ? selectedUrns : [])
773-
.filter((urn) => !selectedForRemoval.has(urn))}
774-
tool={tool}
775-
onUpdate={(updates) => onToolUpdate?.(tool, updates)}
776-
isSelected={
777-
selectionMode === "add"
778-
? selectedSet.has(toolId)
779-
: selectedForRemoval.has(toolId)
780-
}
781-
isFocused={toolIndex === focusedToolIndex}
782-
onCheckboxChange={(checked) =>
783-
handleCheckboxChange(toolId, checked)
784-
}
785-
onTestInPlayground={onTestInPlayground}
786-
onRemove={
787-
selectionMode !== "add" && onToolsRemove
788-
? () => onToolsRemove([toolId])
789-
: undefined
790-
}
791-
/>
792-
);
793-
})}
794-
</div>
795-
)}
796-
</div>
797-
))}
822+
{groups.map((group, index) => {
823+
const groupToolIds = group.tools.map(getToolIdentifier);
824+
const currentSelection =
825+
selectionMode === "add" ? selectedSet : selectedForRemoval;
826+
const allSelected = groupToolIds.every((id) =>
827+
currentSelection.has(id),
828+
);
829+
830+
return (
831+
<div
832+
key={`${group.type}-${group.title}-${index}`}
833+
className="w-full"
834+
>
835+
<ToolGroupHeader
836+
group={group}
837+
isExpanded={expandedGroups.has(index)}
838+
onToggle={() => toggleGroup(index)}
839+
isFirstGroup={index === 0}
840+
allSelected={allSelected}
841+
onSelectAll={() => handleSelectAllInGroup(group)}
842+
/>
843+
{expandedGroups.has(index) && (
844+
<div className="w-full">
845+
{group.tools.map((tool) => {
846+
const toolId = getToolIdentifier(tool);
847+
const toolIndex = toolIndexMap.get(toolId) ?? -1;
848+
849+
return (
850+
<ToolRow
851+
key={tool.canonicalName}
852+
groupName={group.title}
853+
availableToolUrns={toolset?.tools
854+
?.map((t) => t.toolUrn)
855+
.concat(selectionMode === "add" ? selectedUrns : [])
856+
.filter((urn) => !selectedForRemoval.has(urn))}
857+
tool={tool}
858+
onUpdate={(updates) => onToolUpdate?.(tool, updates)}
859+
isSelected={
860+
selectionMode === "add"
861+
? selectedSet.has(toolId)
862+
: selectedForRemoval.has(toolId)
863+
}
864+
isFocused={toolIndex === focusedToolIndex}
865+
onCheckboxChange={(checked) =>
866+
handleCheckboxChange(toolId, checked)
867+
}
868+
onTestInPlayground={onTestInPlayground}
869+
onRemove={
870+
selectionMode !== "add" && onToolsRemove
871+
? () => onToolsRemove([toolId])
872+
: undefined
873+
}
874+
/>
875+
);
876+
})}
877+
</div>
878+
)}
879+
</div>
880+
);
881+
})}
798882
</div>
799883

800884
{hasChanges && !selectionMode && (

client/dashboard/src/components/ui/sidebar.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { Button, Icon } from "@speakeasy-api/moonshine";
21
import { Input } from "@/components/ui/input";
32
import { Link } from "@/components/ui/link";
43
import { Separator } from "@/components/ui/separator";
@@ -18,6 +17,7 @@ import {
1817
import { useIsMobile } from "@/hooks/use-mobile";
1918
import { cn } from "@/lib/utils";
2019
import { Slot } from "@radix-ui/react-slot";
20+
import { Button, Icon } from "@speakeasy-api/moonshine";
2121
import { VariantProps, cva } from "class-variance-authority";
2222
import * as React from "react";
2323

@@ -528,6 +528,7 @@ function SidebarMenuButton({
528528
)}
529529
{...(props.href && { to: props.href })}
530530
{...(props.href && props.href.startsWith("http") && { external: true })}
531+
{...(props.href && { noIcon: true })}
531532
{...props}
532533
/>
533534
);

0 commit comments

Comments
 (0)