Skip to content

Commit 09c8d6a

Browse files
authored
Merge pull request #18 from AGIBuild/fix/menu-cleanup-on-module-unload
fix/menu cleanup on module unload
2 parents d6199ce + 4eec80b commit 09c8d6a

10 files changed

Lines changed: 261 additions & 0 deletions

File tree

openspec/changes/add-shared-assembly-catalog-observability/design.md renamed to openspec/changes/archive/2025-12-09-add-shared-assembly-catalog-observability/design.md

File renamed without changes.

openspec/changes/add-shared-assembly-catalog-observability/proposal.md renamed to openspec/changes/archive/2025-12-09-add-shared-assembly-catalog-observability/proposal.md

File renamed without changes.

openspec/changes/add-shared-assembly-catalog-observability/specs/runtime/spec.md renamed to openspec/changes/archive/2025-12-09-add-shared-assembly-catalog-observability/specs/runtime/spec.md

File renamed without changes.

openspec/changes/add-shared-assembly-catalog-observability/tasks.md renamed to openspec/changes/archive/2025-12-09-add-shared-assembly-catalog-observability/tasks.md

File renamed without changes.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Change: 修复卸载模块后菜单未移除的问题
2+
3+
## Why
4+
5+
当模块被禁用或卸载时,`ModuleLoader.UnloadAsync` 正确清理了 `IMenuRegistry`,但 Blazor Host 的 `ModuleListViewModel` 没有发送 `MenuItemsRemovedMessage`,导致 `ShellViewModel``ObservableCollection<MenuItem>` 没有更新,菜单项仍然显示在导航栏中。
6+
7+
## What Changes
8+
9+
- Blazor Host: `ModuleListViewModel.ToggleModuleAsync` 在禁用模块后发送 `MenuItemsRemovedMessage`
10+
- 与 Avalonia Host 行为对齐(Avalonia 已正确实现)
11+
12+
## Impact
13+
14+
- Affected specs: `shell-layout`
15+
- Affected code:
16+
- `src/Hosts/Modulus.Host.Blazor/Shell/ViewModels/ModuleListViewModel.cs:157-161`
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
## MODIFIED Requirements
2+
3+
### Requirement: SideNav Component
4+
Each host MUST provide a SideNav/NavDrawer component that renders the navigation menu with main items and bottom items, supports collapse mode, and integrates with the navigation service.
5+
6+
#### Scenario: SideNav renders menu items from registry
7+
- **WHEN** modules register menu items via IMenuRegistry
8+
- **THEN** main items appear in the top section of the SideNav
9+
- **AND** bottom items appear in the bottom section of the SideNav
10+
11+
#### Scenario: SideNav item selection triggers navigation
12+
- **WHEN** user clicks a navigation item
13+
- **THEN** the INavigationService is invoked with the item's NavigationKey
14+
- **AND** navigation guards are evaluated
15+
- **AND** the selected item is visually highlighted if navigation succeeds
16+
17+
#### Scenario: SideNav supports collapsed mode
18+
- **WHEN** the navigation panel is toggled to collapsed state
19+
- **THEN** only icons are displayed with tooltips on hover
20+
- **AND** the panel width reduces to accommodate icons only
21+
22+
#### Scenario: SideNav shows badges on items
23+
- **WHEN** a menu item has a non-zero BadgeCount
24+
- **THEN** the badge is displayed adjacent to the item (visible in both expanded and collapsed modes)
25+
26+
#### Scenario: SideNav respects disabled state
27+
- **WHEN** a menu item has IsEnabled = false
28+
- **THEN** the item is rendered in a disabled visual state
29+
- **AND** clicks on the item are ignored
30+
31+
#### Scenario: SideNav removes menu items when module is unloaded
32+
- **WHEN** 模块被禁用或卸载
33+
- **AND** `MenuItemsRemovedMessage` 被发送
34+
- **THEN** 导航栏中该模块的菜单项被移除
35+
- **AND** 菜单项不再可点击
36+
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
## 1. 实现
2+
3+
- [x] 1.1 Blazor Host: 在 `ModuleListViewModel.ToggleModuleAsync` 禁用模块后添加 `using CommunityToolkit.Mvvm.Messaging;``using Modulus.UI.Abstractions.Messages;`,发送 `MenuItemsRemovedMessage`
4+
5+
## 2. 测试
6+
7+
- [x] 2.1 添加 `ShellViewModelTests.cs``tests/Modulus.Hosts.Tests/`
8+
- [x] 2.2 测试 `Receive_MenuItemsRemovedMessage_RemovesMenuItemsFromMainMenu`
9+
- [x] 2.3 测试 `Receive_MenuItemsRemovedMessage_RemovesMenuItemsFromBottomMenu`
10+
- [x] 2.4 测试 `Receive_MenuItemsRemovedMessage_IgnoresUnrelatedModules`
11+
12+
## 3. 验证
13+
14+
- [x] 3.1 手动测试:禁用模块后菜单项从导航栏移除
15+
- [x] 3.2 手动测试:重新启用模块后菜单项恢复

src/Hosts/Modulus.Host.Blazor/Shell/ViewModels/ModuleListViewModel.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@
77
using System.Threading.Tasks;
88
using CommunityToolkit.Mvvm.ComponentModel;
99
using CommunityToolkit.Mvvm.Input;
10+
using CommunityToolkit.Mvvm.Messaging;
1011
using Microsoft.Extensions.Logging;
1112
using Modulus.Core.Installation;
1213
using Modulus.Core.Runtime;
1314
using Modulus.Infrastructure.Data.Models;
1415
using Modulus.Infrastructure.Data.Repositories;
1516
using Modulus.UI.Abstractions;
17+
using Modulus.UI.Abstractions.Messages;
1618

1719
using DataModuleState = Modulus.Infrastructure.Data.Models.ModuleState;
1820
using RuntimeModuleState = Modulus.Core.Runtime.ModuleState;
@@ -158,6 +160,9 @@ public async Task ToggleModuleAsync(ModuleViewModel moduleVm)
158160
{
159161
await _moduleLoader.UnloadAsync(moduleVm.Id);
160162
await _moduleRepository.UpdateStateAsync(moduleVm.Id, DataModuleState.Disabled);
163+
164+
// Notify ShellViewModel to remove menus (incremental)
165+
WeakReferenceMessenger.Default.Send(new MenuItemsRemovedMessage(moduleVm.Id));
161166
}
162167
else
163168
{

tests/Modulus.Hosts.Tests/Modulus.Hosts.Tests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
</PackageReference>
2121
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
2222
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
23+
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
2324
<PackageReference Include="NSubstitute" Version="5.3.0" />
2425
<PackageReference Include="xunit" Version="2.9.3" />
2526
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
using CommunityToolkit.Mvvm.Messaging;
2+
using Modulus.UI.Abstractions;
3+
using Modulus.UI.Abstractions.Messages;
4+
using NSubstitute;
5+
6+
namespace Modulus.Hosts.Tests;
7+
8+
/// <summary>
9+
/// Tests for ShellViewModel message handling for menu items.
10+
/// These tests verify the behavior of MenuItemsRemovedMessage handling
11+
/// which is critical for cleaning up menus when modules are unloaded.
12+
/// </summary>
13+
public class ShellViewModelTests : IDisposable
14+
{
15+
private readonly IMenuRegistry _menuRegistry;
16+
private readonly TestableShellViewModel _viewModel;
17+
18+
public ShellViewModelTests()
19+
{
20+
_menuRegistry = Substitute.For<IMenuRegistry>();
21+
_menuRegistry.GetItems(MenuLocation.Main).Returns(new List<MenuItem>());
22+
_menuRegistry.GetItems(MenuLocation.Bottom).Returns(new List<MenuItem>());
23+
24+
_viewModel = new TestableShellViewModel(_menuRegistry);
25+
}
26+
27+
public void Dispose()
28+
{
29+
WeakReferenceMessenger.Default.UnregisterAll(_viewModel);
30+
}
31+
32+
[Fact]
33+
public void Receive_MenuItemsRemovedMessage_RemovesMenuItemsFromMainMenu()
34+
{
35+
// Arrange
36+
const string moduleId = "test-module";
37+
var menuItem = new MenuItem("menu1", "Test Menu", IconKind.Home, "/test", MenuLocation.Main)
38+
{
39+
ModuleId = moduleId
40+
};
41+
_viewModel.MainMenuItems.Add(menuItem);
42+
_viewModel.MainMenuItems.Add(new MenuItem("other", "Other", IconKind.Home, "/other", MenuLocation.Main));
43+
44+
// Act
45+
_viewModel.Receive(new MenuItemsRemovedMessage(moduleId));
46+
47+
// Assert
48+
Assert.Single(_viewModel.MainMenuItems);
49+
Assert.DoesNotContain(_viewModel.MainMenuItems, m => m.ModuleId == moduleId);
50+
}
51+
52+
[Fact]
53+
public void Receive_MenuItemsRemovedMessage_RemovesMenuItemsFromBottomMenu()
54+
{
55+
// Arrange
56+
const string moduleId = "test-module";
57+
var menuItem = new MenuItem("menu1", "Settings", IconKind.Settings, "/settings", MenuLocation.Bottom)
58+
{
59+
ModuleId = moduleId
60+
};
61+
_viewModel.BottomMenuItems.Add(menuItem);
62+
_viewModel.BottomMenuItems.Add(new MenuItem("other", "Other", IconKind.Settings, "/other", MenuLocation.Bottom));
63+
64+
// Act
65+
_viewModel.Receive(new MenuItemsRemovedMessage(moduleId));
66+
67+
// Assert
68+
Assert.Single(_viewModel.BottomMenuItems);
69+
Assert.DoesNotContain(_viewModel.BottomMenuItems, m => m.ModuleId == moduleId);
70+
}
71+
72+
[Fact]
73+
public void Receive_MenuItemsRemovedMessage_IgnoresUnrelatedModules()
74+
{
75+
// Arrange
76+
const string moduleId1 = "module-1";
77+
const string moduleId2 = "module-2";
78+
79+
var menu1 = new MenuItem("menu1", "Menu 1", IconKind.Home, "/test1", MenuLocation.Main)
80+
{
81+
ModuleId = moduleId1
82+
};
83+
var menu2 = new MenuItem("menu2", "Menu 2", IconKind.Home, "/test2", MenuLocation.Main)
84+
{
85+
ModuleId = moduleId2
86+
};
87+
_viewModel.MainMenuItems.Add(menu1);
88+
_viewModel.MainMenuItems.Add(menu2);
89+
90+
// Act - Remove only module-1's menus
91+
_viewModel.Receive(new MenuItemsRemovedMessage(moduleId1));
92+
93+
// Assert - module-2's menu should remain
94+
Assert.Single(_viewModel.MainMenuItems);
95+
Assert.Contains(_viewModel.MainMenuItems, m => m.ModuleId == moduleId2);
96+
Assert.DoesNotContain(_viewModel.MainMenuItems, m => m.ModuleId == moduleId1);
97+
}
98+
99+
[Fact]
100+
public void Receive_MenuItemsRemovedMessage_RemovesMultipleItemsFromSameModule()
101+
{
102+
// Arrange
103+
const string moduleId = "multi-menu-module";
104+
105+
_viewModel.MainMenuItems.Add(new MenuItem("m1", "Menu 1", IconKind.Home, "/1") { ModuleId = moduleId });
106+
_viewModel.MainMenuItems.Add(new MenuItem("m2", "Menu 2", IconKind.Home, "/2") { ModuleId = moduleId });
107+
_viewModel.MainMenuItems.Add(new MenuItem("m3", "Menu 3", IconKind.Home, "/3") { ModuleId = moduleId });
108+
_viewModel.MainMenuItems.Add(new MenuItem("other", "Other", IconKind.Home, "/other") { ModuleId = "other-module" });
109+
110+
// Act
111+
_viewModel.Receive(new MenuItemsRemovedMessage(moduleId));
112+
113+
// Assert
114+
Assert.Single(_viewModel.MainMenuItems);
115+
Assert.Equal("other-module", _viewModel.MainMenuItems.First().ModuleId);
116+
}
117+
118+
[Fact]
119+
public void Receive_MenuItemsRemovedMessage_HandlesEmptyCollections()
120+
{
121+
// Arrange - collections are empty by default
122+
Assert.Empty(_viewModel.MainMenuItems);
123+
Assert.Empty(_viewModel.BottomMenuItems);
124+
125+
// Act - should not throw
126+
var exception = Record.Exception(() =>
127+
_viewModel.Receive(new MenuItemsRemovedMessage("non-existent")));
128+
129+
// Assert
130+
Assert.Null(exception);
131+
Assert.Empty(_viewModel.MainMenuItems);
132+
Assert.Empty(_viewModel.BottomMenuItems);
133+
}
134+
135+
[Fact]
136+
public void Receive_MenuItemsRemovedMessage_RemovesFromBothMenusSimultaneously()
137+
{
138+
// Arrange
139+
const string moduleId = "test-module";
140+
141+
_viewModel.MainMenuItems.Add(new MenuItem("main1", "Main", IconKind.Home, "/main") { ModuleId = moduleId });
142+
_viewModel.BottomMenuItems.Add(new MenuItem("bottom1", "Bottom", IconKind.Settings, "/bottom") { ModuleId = moduleId });
143+
144+
// Act
145+
_viewModel.Receive(new MenuItemsRemovedMessage(moduleId));
146+
147+
// Assert
148+
Assert.Empty(_viewModel.MainMenuItems);
149+
Assert.Empty(_viewModel.BottomMenuItems);
150+
}
151+
}
152+
153+
/// <summary>
154+
/// Testable ShellViewModel that mimics the behavior of both Blazor and Avalonia ShellViewModels
155+
/// for message handling. This isolates the menu removal logic for unit testing.
156+
/// </summary>
157+
internal class TestableShellViewModel : IRecipient<MenuItemsRemovedMessage>
158+
{
159+
private readonly IMenuRegistry _menuRegistry;
160+
161+
public System.Collections.ObjectModel.ObservableCollection<MenuItem> MainMenuItems { get; } = new();
162+
public System.Collections.ObjectModel.ObservableCollection<MenuItem> BottomMenuItems { get; } = new();
163+
164+
public TestableShellViewModel(IMenuRegistry menuRegistry)
165+
{
166+
_menuRegistry = menuRegistry;
167+
WeakReferenceMessenger.Default.Register(this);
168+
}
169+
170+
/// <summary>
171+
/// Handle incremental menu removal - mirrors the implementation in ShellViewModel.
172+
/// </summary>
173+
public void Receive(MenuItemsRemovedMessage message)
174+
{
175+
var mainToRemove = MainMenuItems.Where(m => m.ModuleId == message.ModuleId).ToList();
176+
foreach (var item in mainToRemove)
177+
{
178+
MainMenuItems.Remove(item);
179+
}
180+
181+
var bottomToRemove = BottomMenuItems.Where(m => m.ModuleId == message.ModuleId).ToList();
182+
foreach (var item in bottomToRemove)
183+
{
184+
BottomMenuItems.Remove(item);
185+
}
186+
}
187+
}
188+

0 commit comments

Comments
 (0)