Skip to content

Commit 11814c4

Browse files
committed
feat(ui): link dashboard stat cards to detail pages
Add Rust unit tests for workspace binding labels, label normalization, and SpaceService::update. Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent 726492e commit 11814c4

4 files changed

Lines changed: 228 additions & 4 deletions

File tree

apps/desktop/src-tauri/src/commands/workspace_binding.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -713,3 +713,23 @@ pub async fn get_workspace_effective_features(
713713
server_totals,
714714
})
715715
}
716+
717+
#[cfg(test)]
718+
mod tests {
719+
use super::normalize_label;
720+
721+
#[test]
722+
fn normalize_label_none_and_empty() {
723+
assert_eq!(normalize_label(&None), None);
724+
assert_eq!(normalize_label(&Some(String::new())), None);
725+
assert_eq!(normalize_label(&Some(" ".to_string())), None);
726+
}
727+
728+
#[test]
729+
fn normalize_label_trims_non_empty() {
730+
assert_eq!(
731+
normalize_label(&Some(" My Project ".to_string())),
732+
Some("My Project".to_string())
733+
);
734+
}
735+
}

apps/desktop/src/App.tsx

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,7 @@ function App() {
390390
}
391391

392392
function DashboardView() {
393+
const navigateTo = useNavigateTo();
393394
const [stats, setStats] = useState({
394395
installedServers: 0,
395396
connectedServers: 0,
@@ -399,6 +400,9 @@ function DashboardView() {
399400
});
400401
const viewSpace = useViewSpace();
401402

403+
const statCardClass =
404+
'cursor-pointer transition-all hover:shadow-lg hover:scale-[1.01] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/50';
405+
402406
// Load stats on mount and when gateway changes
403407
const loadStats = async () => {
404408
try {
@@ -460,7 +464,20 @@ function DashboardView() {
460464

461465
{/* Stats Grid */}
462466
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4" data-testid="dashboard-stats-grid">
463-
<Card data-testid="stat-servers">
467+
<Card
468+
className={statCardClass}
469+
data-testid="stat-servers"
470+
role="button"
471+
tabIndex={0}
472+
aria-label="Go to Servers"
473+
onClick={() => navigateTo('servers')}
474+
onKeyDown={(e) => {
475+
if (e.key === 'Enter' || e.key === ' ') {
476+
e.preventDefault();
477+
navigateTo('servers');
478+
}
479+
}}
480+
>
464481
<CardHeader>
465482
<CardTitle className="flex items-center gap-2 text-base">
466483
<Server className="h-5 w-5 text-primary-500" />
@@ -473,7 +490,20 @@ function DashboardView() {
473490
</CardContent>
474491
</Card>
475492

476-
<Card data-testid="stat-featuresets">
493+
<Card
494+
className={statCardClass}
495+
data-testid="stat-featuresets"
496+
role="button"
497+
tabIndex={0}
498+
aria-label="Go to Feature Sets"
499+
onClick={() => navigateTo('featuresets')}
500+
onKeyDown={(e) => {
501+
if (e.key === 'Enter' || e.key === ' ') {
502+
e.preventDefault();
503+
navigateTo('featuresets');
504+
}
505+
}}
506+
>
477507
<CardHeader>
478508
<CardTitle className="flex items-center gap-2 text-base">
479509
<Wrench className="h-5 w-5 text-primary-500" />
@@ -486,7 +516,20 @@ function DashboardView() {
486516
</CardContent>
487517
</Card>
488518

489-
<Card data-testid="stat-clients">
519+
<Card
520+
className={statCardClass}
521+
data-testid="stat-clients"
522+
role="button"
523+
tabIndex={0}
524+
aria-label="Go to Clients"
525+
onClick={() => navigateTo('clients')}
526+
onKeyDown={(e) => {
527+
if (e.key === 'Enter' || e.key === ' ') {
528+
e.preventDefault();
529+
navigateTo('clients');
530+
}
531+
}}
532+
>
490533
<CardHeader>
491534
<CardTitle className="flex items-center gap-2 text-base">
492535
<Monitor className="h-5 w-5 text-primary-500" />
@@ -499,7 +542,20 @@ function DashboardView() {
499542
</CardContent>
500543
</Card>
501544

502-
<Card data-testid="stat-active-space">
545+
<Card
546+
className={statCardClass}
547+
data-testid="stat-active-space"
548+
role="button"
549+
tabIndex={0}
550+
aria-label="Go to Spaces"
551+
onClick={() => navigateTo('spaces')}
552+
onKeyDown={(e) => {
553+
if (e.key === 'Enter' || e.key === ' ') {
554+
e.preventDefault();
555+
navigateTo('spaces');
556+
}
557+
}}
558+
>
503559
<CardHeader>
504560
<CardTitle className="flex items-center gap-2 text-base">
505561
<Globe className="h-5 w-5 text-primary-500" />

crates/mcpmux-core/src/service/space_service.rs

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,3 +126,126 @@ impl SpaceService {
126126
self.repository.get_default().await
127127
}
128128
}
129+
130+
#[cfg(test)]
131+
mod tests {
132+
use super::*;
133+
use async_trait::async_trait;
134+
use std::collections::HashMap;
135+
use tokio::sync::RwLock;
136+
137+
struct InMemorySpaceRepo {
138+
spaces: RwLock<HashMap<Uuid, Space>>,
139+
}
140+
141+
async fn repo_with_space(space: Space) -> Arc<InMemorySpaceRepo> {
142+
let repo = Arc::new(InMemorySpaceRepo {
143+
spaces: RwLock::new(HashMap::new()),
144+
});
145+
repo.spaces.write().await.insert(space.id, space);
146+
repo
147+
}
148+
149+
#[async_trait]
150+
impl SpaceRepository for InMemorySpaceRepo {
151+
async fn list(&self) -> crate::repository::RepoResult<Vec<Space>> {
152+
Ok(self.spaces.read().await.values().cloned().collect())
153+
}
154+
155+
async fn get(&self, id: &Uuid) -> crate::repository::RepoResult<Option<Space>> {
156+
Ok(self.spaces.read().await.get(id).cloned())
157+
}
158+
159+
async fn create(&self, space: &Space) -> crate::repository::RepoResult<()> {
160+
self.spaces.write().await.insert(space.id, space.clone());
161+
Ok(())
162+
}
163+
164+
async fn update(&self, space: &Space) -> crate::repository::RepoResult<()> {
165+
self.spaces.write().await.insert(space.id, space.clone());
166+
Ok(())
167+
}
168+
169+
async fn delete(&self, id: &Uuid) -> crate::repository::RepoResult<()> {
170+
self.spaces.write().await.remove(id);
171+
Ok(())
172+
}
173+
174+
async fn get_default(&self) -> crate::repository::RepoResult<Option<Space>> {
175+
Ok(self
176+
.spaces
177+
.read()
178+
.await
179+
.values()
180+
.find(|s| s.is_default)
181+
.cloned())
182+
}
183+
184+
async fn set_default(&self, id: &Uuid) -> crate::repository::RepoResult<()> {
185+
let mut spaces = self.spaces.write().await;
186+
for space in spaces.values_mut() {
187+
space.is_default = false;
188+
}
189+
if let Some(space) = spaces.get_mut(id) {
190+
space.is_default = true;
191+
}
192+
Ok(())
193+
}
194+
}
195+
196+
#[tokio::test]
197+
async fn update_changes_name_and_bumps_updated_at() {
198+
let original = Space::new("Original");
199+
let id = original.id;
200+
let original_updated_at = original.updated_at;
201+
let repo = repo_with_space(original).await;
202+
let service = SpaceService::new(repo);
203+
204+
let updated = service
205+
.update(id, Some("Renamed".to_string()), None, None)
206+
.await
207+
.unwrap();
208+
209+
assert_eq!(updated.name, "Renamed");
210+
assert!(updated.updated_at >= original_updated_at);
211+
212+
let loaded = service.get(&id).await.unwrap().expect("space exists");
213+
assert_eq!(loaded.name, "Renamed");
214+
}
215+
216+
#[tokio::test]
217+
async fn update_applies_icon_and_description() {
218+
let space = Space::new("Space");
219+
let id = space.id;
220+
let repo = repo_with_space(space).await;
221+
let service = SpaceService::new(repo);
222+
223+
let updated = service
224+
.update(
225+
id,
226+
None,
227+
Some("rocket".to_string()),
228+
Some("Side project".to_string()),
229+
)
230+
.await
231+
.unwrap();
232+
233+
assert_eq!(updated.icon.as_deref(), Some("rocket"));
234+
assert_eq!(updated.description.as_deref(), Some("Side project"));
235+
}
236+
237+
#[tokio::test]
238+
async fn update_returns_not_found_for_missing_space() {
239+
let repo = Arc::new(InMemorySpaceRepo {
240+
spaces: RwLock::new(HashMap::new()),
241+
});
242+
let service = SpaceService::new(repo);
243+
244+
let err = service
245+
.update(Uuid::new_v4(), Some("nope".to_string()), None, None)
246+
.await
247+
.unwrap_err();
248+
249+
assert!(err.to_string().contains("Space not found"));
250+
}
251+
}

crates/mcpmux-storage/src/repositories/workspace_binding_repository.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,31 @@ mod tests {
370370
assert_eq!(got.workspace_root, root);
371371
assert_eq!(got.space_id, space_id);
372372
assert_eq!(got.feature_set_ids, vec![fs_id]);
373+
assert_eq!(got.label, None);
374+
}
375+
376+
#[tokio::test]
377+
async fn test_label_round_trip() {
378+
let (repo, space_id, fs_id) = fixture().await;
379+
let root = if cfg!(windows) { "d:\\labeled" } else { "/labeled" };
380+
let mut binding = WorkspaceBinding::new(root, space_id, fs_id);
381+
binding.label = Some("My Project".to_string());
382+
repo.create(&binding).await.unwrap();
383+
384+
let got = repo.get(&binding.id).await.unwrap().unwrap();
385+
assert_eq!(got.label.as_deref(), Some("My Project"));
386+
387+
let mut updated = got;
388+
updated.label = None;
389+
repo.update(&updated).await.unwrap();
390+
let cleared = repo.get(&binding.id).await.unwrap().unwrap();
391+
assert_eq!(cleared.label, None);
392+
393+
let mut relabeled = cleared;
394+
relabeled.label = Some("Renamed".to_string());
395+
repo.update(&relabeled).await.unwrap();
396+
let final_got = repo.get(&binding.id).await.unwrap().unwrap();
397+
assert_eq!(final_got.label.as_deref(), Some("Renamed"));
373398
}
374399

375400
#[tokio::test]

0 commit comments

Comments
 (0)