mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 12:17:34 +00:00
feat(fleet): tidy the Overview toolbar and shorten tab labels (#1361)
- Move the Add node button into the Overview toolbar beside the Grid/Topology toggle so it sits with the view it acts on instead of showing on every Fleet tab. It stays admin-only. - Collapse the node search to an icon button that expands to the full input on click and collapses again on blur once the query is empty, reclaiming toolbar width. An active query keeps it open. - Match the sort-direction toggle and the sort dropdown to the outlined dark fill of the Filters button for a consistent toolbar row. - Swap the Check Updates icon to the refresh-with-dot glyph. - Rename two tabs: Fleet Actions to Actions, Dependencies to Map (display labels only; internal keys unchanged). Update the feature docs to the new tab labels.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
RefreshCw, Search, Camera, Plus, FileDown,
|
||||
RefreshCw, RefreshCcwDot, Camera, FileDown,
|
||||
Network, SlidersHorizontal,
|
||||
Send, KeyRound, ArrowLeftRight, Wrench, Workflow,
|
||||
} from 'lucide-react';
|
||||
@@ -29,7 +29,6 @@ import { FleetActionsTab } from './fleet/FleetActions/FleetActionsTab';
|
||||
import { SecretsTab } from './fleet/secrets/SecretsTab';
|
||||
import { DependencyMapTab } from './fleet/DependencyMapTab';
|
||||
import { useNodeActions } from './nodes/useNodeActions';
|
||||
import { SettingsPrimaryButton } from './settings/SettingsActions';
|
||||
|
||||
interface FleetViewProps {
|
||||
onNavigateToNode: (nodeId: number, stackName: string) => void;
|
||||
@@ -94,7 +93,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
</TabsHighlightItem>
|
||||
<TabsHighlightItem value="dependencies">
|
||||
<TabsTrigger value="dependencies">
|
||||
<Workflow className="w-4 h-4 mr-1.5" />Dependencies
|
||||
<Workflow className="w-4 h-4 mr-1.5" />Map
|
||||
</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
<span aria-hidden className="self-center mx-1 h-4 w-px bg-border" />
|
||||
@@ -121,7 +120,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
)}
|
||||
<TabsHighlightItem value="actions">
|
||||
<TabsTrigger value="actions">
|
||||
<Wrench className="w-4 h-4 mr-1.5" />Fleet Actions
|
||||
<Wrench className="w-4 h-4 mr-1.5" />Actions
|
||||
</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
{isPaid && isAdmin && (
|
||||
@@ -140,7 +139,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
onClick={updateStatus.checkUpdates}
|
||||
className="gap-2"
|
||||
>
|
||||
<Search className="w-4 h-4" />
|
||||
<RefreshCcwDot className="w-4 h-4" />
|
||||
Check Updates
|
||||
</Button>
|
||||
<Button
|
||||
@@ -165,16 +164,6 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
{exporting ? 'Exporting…' : 'Export Dossier'}
|
||||
</Button>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<SettingsPrimaryButton
|
||||
size="sm"
|
||||
onClick={openCreate}
|
||||
className="gap-1"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add node
|
||||
</SettingsPrimaryButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -205,6 +194,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
onCordonChange={() => { void overview.fetchOverview(true); }}
|
||||
onEditNode={isAdmin ? openEdit : undefined}
|
||||
onDeleteNode={isAdmin ? openDelete : undefined}
|
||||
onAddNode={isAdmin ? openCreate : undefined}
|
||||
topologyMode={topology.prefs.mode}
|
||||
onTopologyModeChange={topology.setMode}
|
||||
topologyPositions={topology.prefs.positions}
|
||||
|
||||
@@ -39,6 +39,7 @@ interface OverviewTabProps {
|
||||
onTopologyModeChange: (mode: LayoutMode) => void;
|
||||
topologyPositions: SavedPositions;
|
||||
onTopologyPositionsChange: (positions: SavedPositions) => void;
|
||||
onAddNode?: () => void;
|
||||
}
|
||||
|
||||
export function OverviewTab({
|
||||
@@ -71,6 +72,7 @@ export function OverviewTab({
|
||||
onTopologyModeChange,
|
||||
topologyPositions,
|
||||
onTopologyPositionsChange,
|
||||
onAddNode,
|
||||
}: OverviewTabProps) {
|
||||
return (
|
||||
<>
|
||||
@@ -113,6 +115,7 @@ export function OverviewTab({
|
||||
labelFilters={labelFilters}
|
||||
onLabelFiltersChange={onLabelFiltersChange}
|
||||
onClearFilters={onClearFilters}
|
||||
onAddNode={onAddNode}
|
||||
/>
|
||||
|
||||
{viewMode === 'topology' && processedNodes.length > 0 ? (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Search, ArrowUpDown, AlertTriangle, Play, Square,
|
||||
LayoutGrid, Network, SlidersHorizontal,
|
||||
LayoutGrid, Network, SlidersHorizontal, Plus,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -49,6 +49,7 @@ interface OverviewToolbarProps {
|
||||
labelFilters: Set<string>;
|
||||
onLabelFiltersChange: (filters: Set<string>) => void;
|
||||
onClearFilters: () => void;
|
||||
onAddNode?: () => void;
|
||||
}
|
||||
|
||||
export function OverviewToolbar({
|
||||
@@ -62,6 +63,7 @@ export function OverviewToolbar({
|
||||
labelFilters,
|
||||
onLabelFiltersChange,
|
||||
onClearFilters,
|
||||
onAddNode,
|
||||
}: OverviewToolbarProps) {
|
||||
const showGridControls = viewMode === 'grid';
|
||||
const activeFilterCount =
|
||||
@@ -75,29 +77,56 @@ export function OverviewToolbar({
|
||||
[fleetPalette],
|
||||
);
|
||||
|
||||
// Collapsed by default to a single icon button; expands to the full input on
|
||||
// click and collapses again on blur once the query is cleared. An active
|
||||
// query keeps it open so the filter stays visible and editable.
|
||||
const [searchExpanded, setSearchExpanded] = useState(false);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const searchOpen = searchExpanded || searchQuery !== '';
|
||||
|
||||
useEffect(() => {
|
||||
if (searchExpanded) searchInputRef.current?.focus();
|
||||
}, [searchExpanded]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 mb-4">
|
||||
{showGridControls && (
|
||||
<>
|
||||
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
placeholder="Search nodes or stacks..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchQueryChange(e.target.value)}
|
||||
className="pl-9 h-9"
|
||||
/>
|
||||
</div>
|
||||
{searchOpen ? (
|
||||
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
placeholder="Search nodes or stacks..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchQueryChange(e.target.value)}
|
||||
onBlur={() => { if (searchQuery === '') setSearchExpanded(false); }}
|
||||
className="pl-9 h-9"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 w-9 p-0 shrink-0"
|
||||
onClick={() => setSearchExpanded(true)}
|
||||
title="Search nodes or stacks"
|
||||
aria-label="Search nodes or stacks"
|
||||
>
|
||||
<Search className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
<div className="w-40">
|
||||
<Combobox
|
||||
options={SORT_OPTIONS}
|
||||
value={prefs.sortBy}
|
||||
onValueChange={(v) => onPrefsChange({ sortBy: v as SortField })}
|
||||
placeholder="Sort by..."
|
||||
className="[&>button]:!bg-background"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 w-9 p-0 shrink-0"
|
||||
onClick={() => onPrefsChange({ sortDir: prefs.sortDir === 'asc' ? 'desc' : 'asc' })}
|
||||
@@ -204,6 +233,13 @@ export function OverviewToolbar({
|
||||
options={VIEW_MODE_OPTIONS}
|
||||
className="ml-auto shrink-0 shadow-card-bevel"
|
||||
/>
|
||||
|
||||
{onAddNode && (
|
||||
<Button size="sm" className="gap-1.5 shrink-0 h-9" onClick={onAddNode}>
|
||||
<Plus className="w-4 h-4" strokeWidth={1.5} />
|
||||
Add node
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,19 +23,47 @@ function props(overrides: Partial<React.ComponentProps<typeof OverviewToolbar>>
|
||||
}
|
||||
|
||||
describe('OverviewToolbar', () => {
|
||||
it('shows search and sort controls in grid mode', () => {
|
||||
it('collapses the search to an icon button by default and expands it on click', () => {
|
||||
render(<OverviewToolbar {...props()} />);
|
||||
expect(screen.queryByPlaceholderText('Search nodes or stacks...')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Search nodes or stacks' }));
|
||||
expect(screen.getByPlaceholderText('Search nodes or stacks...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the search expanded when a query is already active', () => {
|
||||
render(<OverviewToolbar {...props({ searchQuery: 'plex' })} />);
|
||||
expect(screen.getByDisplayValue('plex')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('focuses the input when the search expands', () => {
|
||||
render(<OverviewToolbar {...props()} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Search nodes or stacks' }));
|
||||
expect(screen.getByPlaceholderText('Search nodes or stacks...')).toHaveFocus();
|
||||
});
|
||||
|
||||
it('collapses back to the icon button on blur when the query is empty', () => {
|
||||
render(<OverviewToolbar {...props()} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Search nodes or stacks' }));
|
||||
fireEvent.blur(screen.getByPlaceholderText('Search nodes or stacks...'));
|
||||
expect(screen.queryByPlaceholderText('Search nodes or stacks...')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Search nodes or stacks' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('stays expanded on blur while a query is active', () => {
|
||||
render(<OverviewToolbar {...props({ searchQuery: 'plex' })} />);
|
||||
fireEvent.blur(screen.getByPlaceholderText('Search nodes or stacks...'));
|
||||
expect(screen.getByDisplayValue('plex')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides grid controls in topology mode', () => {
|
||||
render(<OverviewToolbar {...props({ viewMode: 'topology' })} />);
|
||||
expect(screen.queryByPlaceholderText('Search nodes or stacks...')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('forwards search input changes', () => {
|
||||
it('forwards search input changes once expanded', () => {
|
||||
const onSearchQueryChange = vi.fn();
|
||||
render(<OverviewToolbar {...props({ onSearchQueryChange })} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Search nodes or stacks' }));
|
||||
fireEvent.change(screen.getByPlaceholderText('Search nodes or stacks...'), { target: { value: 'web' } });
|
||||
expect(onSearchQueryChange).toHaveBeenCalledWith('web');
|
||||
});
|
||||
@@ -53,4 +81,16 @@ describe('OverviewToolbar', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Filters/ }));
|
||||
expect(screen.queryByText('Tags')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the Add node button and fires onAddNode when provided', () => {
|
||||
const onAddNode = vi.fn();
|
||||
render(<OverviewToolbar {...props({ onAddNode })} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add node' }));
|
||||
expect(onAddNode).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('omits the Add node button when onAddNode is not provided', () => {
|
||||
render(<OverviewToolbar {...props()} />);
|
||||
expect(screen.queryByRole('button', { name: 'Add node' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user