docs(phase7): implementation plan + task persistence (8 tasks)
This commit is contained in:
@@ -0,0 +1,494 @@
|
||||
# Phase 7 — Client.UI Alarm Ack/Shelve/Confirm Buttons — Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task. Read this plan + `docs/plans/2026-06-16-stillpending-phase-7-client-alarm-buttons-design.md` first. Branch `feat/stillpending-phase-7-client-alarm-buttons` (off master `ad3ec9d9`) already exists with the design committed (`573728b5`).
|
||||
|
||||
**Goal:** Give the Avalonia desktop client per-alarm **Acknowledge / Shelve / Confirm** via the existing right-click context menu, backed by the already-implemented `IOpcUaClientService` methods.
|
||||
|
||||
**Architecture:** Avalonia + CommunityToolkit.Mvvm. Two new `AlarmsViewModel` async methods (`ShelveAlarmAsync`, `ConfirmAlarmAsync`) mirroring the existing `AcknowledgeAlarmAsync`; two new `AlarmEventViewModel` enable predicates; two new dialog windows (`ShelveAlarmWindow`, `ConfirmAlarmWindow`); and the runtime context-menu builder in `AlarmsView.axaml.cs` extended to 3 items with per-item enablement. The VM methods + predicates are unit-tested against the UI `FakeOpcUaClientService`; the dialogs + menu are proven by build + a launch/connect smoke.
|
||||
|
||||
**Tech Stack:** C#/.NET 10, Avalonia, CommunityToolkit.Mvvm, OPCFoundation `Opc.Ua` (`StatusCode`), xUnit + Shouldly.
|
||||
|
||||
**Global rules (every task):** TDD red→green for the VM methods + predicates. Stage by path — never `git add .`; never stage `sql_login.txt`, `src/Server/.../Host/pki/`, `pending.md`, `current.md`, `docker-dev/docker-compose.yml`, `stillpending.md`. No `--no-verify`, no force-push. **NO EF migration, NO Commons/`IOpcUaClientService`/CLI contract change** (the service methods already exist). If `git commit` hits `.git/index.lock`, wait 2s + retry up to 5×.
|
||||
|
||||
**Dependency graph:** Task 1 → Task 2 → {Task 3 ∥ Task 4} → Task 5 → Task 6 → Task 7 → Task 8.
|
||||
|
||||
---
|
||||
|
||||
### Task 0: Feature branch *(done)*
|
||||
Branch `feat/stillpending-phase-7-client-alarm-buttons` off `ad3ec9d9`; design committed `573728b5`. No action.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Extend the UI `FakeOpcUaClientService` with Shelve/Confirm call-tracking
|
||||
|
||||
**Classification:** small · **Est:** ~3 min · **Parallelizable with:** none
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/Client/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/Fakes/FakeOpcUaClientService.cs`
|
||||
|
||||
**Context:** The fake already implements `ConfirmAlarmAsync`/`ShelveAlarmAsync` returning `ConfirmResult`/`ShelveResult`, but does NOT track call args or support a thrown exception (unlike `AcknowledgeAlarmAsync` which has `AcknowledgeCallCount` + `AcknowledgeException`). Add the parallel tracking so the Task-2 VM tests can assert the service was called with the right args.
|
||||
|
||||
**Step 1: Add tracking fields + exception props** (near the existing `ConfirmResult` / `ShelveResult`):
|
||||
```csharp
|
||||
/// <summary>Gets or sets the exception thrown to simulate alarm confirmation failures in the UI.</summary>
|
||||
public Exception? ConfirmException { get; set; }
|
||||
/// <summary>Gets the number of times ConfirmAlarmAsync has been called.</summary>
|
||||
public int ConfirmCallCount { get; private set; }
|
||||
/// <summary>Gets the (conditionNodeId, eventId, comment) of the last ConfirmAlarmAsync call.</summary>
|
||||
public (string ConditionNodeId, byte[] EventId, string Comment)? LastConfirmCall { get; private set; }
|
||||
|
||||
/// <summary>Gets or sets the exception thrown to simulate alarm shelve failures in the UI.</summary>
|
||||
public Exception? ShelveException { get; set; }
|
||||
/// <summary>Gets the number of times ShelveAlarmAsync has been called.</summary>
|
||||
public int ShelveCallCount { get; private set; }
|
||||
/// <summary>Gets the (conditionNodeId, kind, shelvingTimeSeconds) of the last ShelveAlarmAsync call.</summary>
|
||||
public (string ConditionNodeId, ShelveKind Kind, double ShelvingTimeSeconds)? LastShelveCall { get; private set; }
|
||||
```
|
||||
|
||||
**Step 2: Update the two methods** to record + honor the exception (mirror `AcknowledgeAlarmAsync`):
|
||||
```csharp
|
||||
public Task<StatusCode> ConfirmAlarmAsync(string conditionNodeId, byte[] eventId, string comment,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ConfirmCallCount++;
|
||||
LastConfirmCall = (conditionNodeId, eventId, comment);
|
||||
if (ConfirmException != null) throw ConfirmException;
|
||||
return Task.FromResult(ConfirmResult);
|
||||
}
|
||||
|
||||
public Task<StatusCode> ShelveAlarmAsync(string conditionNodeId, ShelveKind kind, double shelvingTimeSeconds = 0,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ShelveCallCount++;
|
||||
LastShelveCall = (conditionNodeId, kind, shelvingTimeSeconds);
|
||||
if (ShelveException != null) throw ShelveException;
|
||||
return Task.FromResult(ShelveResult);
|
||||
}
|
||||
```
|
||||
(`ShelveKind` is already imported via `using ZB.MOM.WW.OtOpcUa.Client.Shared.Models;` at the top.)
|
||||
|
||||
**Step 3: Build the test project** — `dotnet build tests/Client/ZB.MOM.WW.OtOpcUa.Client.UI.Tests` → 0 errors (no test consumes the new members yet; this just confirms it compiles).
|
||||
|
||||
**Step 4: Commit**
|
||||
```bash
|
||||
git add tests/Client/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/Fakes/FakeOpcUaClientService.cs
|
||||
git commit -m "test(client-ui): track Shelve/Confirm calls + exceptions in FakeOpcUaClientService"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: `AlarmsViewModel.ShelveAlarmAsync` + `ConfirmAlarmAsync` + `CanShelve`/`CanConfirm` (TDD)
|
||||
|
||||
**Classification:** standard · **Est:** ~5 min · **Parallelizable with:** none · **blockedBy:** Task 1
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/AlarmsViewModel.cs` (add 2 methods after `AcknowledgeAlarmAsync` ~:193)
|
||||
- Modify: `src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/AlarmEventViewModel.cs` (add 2 predicates after `CanAcknowledge` :98)
|
||||
- Test: `tests/Client/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/AlarmsViewModelTests.cs`
|
||||
|
||||
**Step 1: Write the failing tests.** Read the existing `AlarmsViewModelTests.cs` first to copy its setup (how the `AlarmsViewModel` is constructed with the `FakeOpcUaClientService`, how `IsConnected` is set, how an `AlarmEventViewModel` is built — mirror the existing Acknowledge tests exactly). Add these cases (names indicative; match the file's naming style):
|
||||
|
||||
```csharp
|
||||
// --- Shelve ---
|
||||
// connected + OneShot: calls service with kind=OneShot, returns success
|
||||
// _service.IsConnected = true; _vm... ; var alarm = new AlarmEventViewModel(... conditionNodeId: "ns=2;s=A", eventId: new byte[]{1});
|
||||
// var (ok, _) = await _vm.ShelveAlarmAsync(alarm, ShelveKind.OneShot, 0);
|
||||
// ok.ShouldBeTrue(); _service.ShelveCallCount.ShouldBe(1);
|
||||
// _service.LastShelveCall!.Value.Kind.ShouldBe(ShelveKind.OneShot);
|
||||
// _service.LastShelveCall!.Value.ConditionNodeId.ShouldBe("ns=2;s=A");
|
||||
// connected + Timed + duration 300: passes ShelvingTimeSeconds=300
|
||||
// connected + Unshelve: kind=Unshelve, success
|
||||
// Timed + duration <= 0: returns (false, ...), ShelveCallCount==0 (no service call)
|
||||
// not connected: returns (false, ...), ShelveCallCount==0
|
||||
// missing ConditionNodeId (null): returns (false, ...), ShelveCallCount==0
|
||||
// service returns Bad (_service.ShelveResult = StatusCodes.BadNodeIdUnknown): returns (false, message contains "Shelve failed")
|
||||
|
||||
// --- Confirm ---
|
||||
// connected: calls service with nodeId+eventId+comment, returns success; ConfirmCallCount==1; LastConfirmCall comment matches
|
||||
// not connected: (false, ...), ConfirmCallCount==0
|
||||
// missing EventId (null): (false, ...), ConfirmCallCount==0
|
||||
// service Bad (_service.ConfirmResult = StatusCodes.BadNodeIdUnknown): (false, message contains "Confirm failed")
|
||||
|
||||
// --- Predicates (pure, construct AlarmEventViewModel directly) ---
|
||||
// CanShelve: true when ConditionNodeId != null; false when null
|
||||
// CanConfirm: true when AckedState && EventId != null && ConditionNodeId != null; false when not acked / null event / null node
|
||||
```
|
||||
|
||||
**Step 2: Run → FAIL** (`ShelveAlarmAsync`/`ConfirmAlarmAsync`/`CanShelve`/`CanConfirm` not defined):
|
||||
`dotnet test tests/Client/ZB.MOM.WW.OtOpcUa.Client.UI.Tests --filter "FullyQualifiedName~AlarmsViewModel"` → compile error / fail.
|
||||
|
||||
**Step 3: Implement.** In `AlarmsViewModel.cs` (ensure `using ZB.MOM.WW.OtOpcUa.Client.Shared.Models;` is present for `ShelveKind` — add it if missing), after `AcknowledgeAlarmAsync`:
|
||||
```csharp
|
||||
/// <summary>Shelves / unshelves an alarm and returns (success, message).</summary>
|
||||
public async Task<(bool Success, string Message)> ShelveAlarmAsync(
|
||||
AlarmEventViewModel alarm, ShelveKind kind, double durationSeconds)
|
||||
{
|
||||
if (!IsConnected || alarm.ConditionNodeId == null)
|
||||
return (false, "Alarm cannot be shelved (not connected or missing ConditionId).");
|
||||
if (kind == ShelveKind.Timed && durationSeconds <= 0)
|
||||
return (false, "Timed shelve requires a positive duration (seconds).");
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _service.ShelveAlarmAsync(alarm.ConditionNodeId, kind, durationSeconds);
|
||||
if (Opc.Ua.StatusCode.IsGood(result))
|
||||
return (true, $"Alarm {kind.ToString().ToLowerInvariant()} succeeded.");
|
||||
return (false, $"Shelve failed: {Helpers.StatusCodeFormatter.Format(result)}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (false, $"Error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Confirms an alarm and returns (success, message).</summary>
|
||||
public async Task<(bool Success, string Message)> ConfirmAlarmAsync(AlarmEventViewModel alarm, string comment)
|
||||
{
|
||||
if (!IsConnected || alarm.EventId == null || alarm.ConditionNodeId == null)
|
||||
return (false, "Alarm cannot be confirmed (missing EventId or ConditionId).");
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _service.ConfirmAlarmAsync(alarm.ConditionNodeId, alarm.EventId, comment);
|
||||
if (Opc.Ua.StatusCode.IsGood(result))
|
||||
return (true, "Alarm confirmed successfully.");
|
||||
return (false, $"Confirm failed: {Helpers.StatusCodeFormatter.Format(result)}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (false, $"Error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
```
|
||||
In `AlarmEventViewModel.cs`, after `CanAcknowledge` (:98):
|
||||
```csharp
|
||||
/// <summary>Whether this alarm can be shelved/unshelved (has a ConditionNodeId).</summary>
|
||||
public bool CanShelve => ConditionNodeId != null;
|
||||
|
||||
/// <summary>Whether this alarm can be confirmed (already acked, has EventId + ConditionNodeId).</summary>
|
||||
public bool CanConfirm => AckedState && EventId != null && ConditionNodeId != null;
|
||||
```
|
||||
|
||||
**Step 4: Run → PASS.** `dotnet test tests/Client/ZB.MOM.WW.OtOpcUa.Client.UI.Tests --filter "FullyQualifiedName~AlarmsViewModel"` → green. Then `dotnet build src/Client/ZB.MOM.WW.OtOpcUa.Client.UI` → 0 errors.
|
||||
|
||||
**Step 5: Commit**
|
||||
```bash
|
||||
git add src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/AlarmsViewModel.cs \
|
||||
src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/AlarmEventViewModel.cs \
|
||||
tests/Client/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/AlarmsViewModelTests.cs
|
||||
git commit -m "feat(client-ui): AlarmsViewModel Shelve/Confirm methods + CanShelve/CanConfirm"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: `ShelveAlarmWindow` dialog (view + code-behind)
|
||||
|
||||
**Classification:** small · **Est:** ~5 min · **Parallelizable with:** Task 4 · **blockedBy:** Task 2
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/Views/ShelveAlarmWindow.axaml`
|
||||
- Create: `src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/Views/ShelveAlarmWindow.axaml.cs`
|
||||
|
||||
**Context:** Model on `AckAlarmWindow.axaml{,.cs}` (read both first). Difference: instead of a comment box, a **ShelveKind selector** (ComboBox) + a **Duration (seconds)** input enabled only for Timed. No comment (the service `ShelveAlarmAsync` takes none).
|
||||
|
||||
**Step 1: `ShelveAlarmWindow.axaml`**
|
||||
```xml
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="ZB.MOM.WW.OtOpcUa.Client.UI.Views.ShelveAlarmWindow"
|
||||
Title="Shelve Alarm" Width="420" SizeToContent="Height" MinHeight="280"
|
||||
WindowStartupLocation="CenterOwner" CanResize="False">
|
||||
<StackPanel Margin="16" Spacing="12">
|
||||
<TextBlock Text="Shelve Alarm" FontWeight="Bold" FontSize="16" />
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Source:" FontSize="12" Foreground="Gray" />
|
||||
<TextBlock Name="SourceText" FontWeight="SemiBold" />
|
||||
</StackPanel>
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Condition:" FontSize="12" Foreground="Gray" />
|
||||
<TextBlock Name="ConditionText" />
|
||||
</StackPanel>
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Shelve type:" FontSize="12" Foreground="Gray" />
|
||||
<ComboBox Name="KindCombo" SelectedIndex="0" HorizontalAlignment="Stretch">
|
||||
<ComboBoxItem>OneShot</ComboBoxItem>
|
||||
<ComboBoxItem>Timed</ComboBoxItem>
|
||||
<ComboBoxItem>Unshelve</ComboBoxItem>
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
<StackPanel Name="DurationPanel" Spacing="4" IsEnabled="False">
|
||||
<TextBlock Text="Duration (seconds):" FontSize="12" Foreground="Gray" />
|
||||
<NumericUpDown Name="DurationInput" Minimum="1" Value="60" Increment="10" FormatString="0" />
|
||||
</StackPanel>
|
||||
<TextBlock Name="ResultText" Foreground="Gray" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right">
|
||||
<Button Name="ShelveButton" Content="Shelve" />
|
||||
<Button Name="CancelButton" Content="Cancel" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Window>
|
||||
```
|
||||
|
||||
**Step 2: `ShelveAlarmWindow.axaml.cs`** (mirror `AckAlarmWindow.axaml.cs`):
|
||||
```csharp
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.UI.ViewModels;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Client.UI.Views;
|
||||
|
||||
public partial class ShelveAlarmWindow : Window
|
||||
{
|
||||
private readonly AlarmsViewModel _alarmsVm;
|
||||
private readonly AlarmEventViewModel _alarm;
|
||||
|
||||
/// <summary>Designer ctor.</summary>
|
||||
public ShelveAlarmWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
_alarmsVm = null!;
|
||||
_alarm = null!;
|
||||
}
|
||||
|
||||
/// <summary>Creates the shelve dialog for an alarm.</summary>
|
||||
public ShelveAlarmWindow(AlarmsViewModel alarmsVm, AlarmEventViewModel alarm)
|
||||
{
|
||||
InitializeComponent();
|
||||
_alarmsVm = alarmsVm;
|
||||
_alarm = alarm;
|
||||
|
||||
var sourceText = this.FindControl<TextBlock>("SourceText");
|
||||
if (sourceText != null) sourceText.Text = alarm.SourceName;
|
||||
|
||||
var conditionText = this.FindControl<TextBlock>("ConditionText");
|
||||
if (conditionText != null) conditionText.Text = $"{alarm.ConditionName} (Severity: {alarm.Severity})";
|
||||
|
||||
var kindCombo = this.FindControl<ComboBox>("KindCombo");
|
||||
if (kindCombo != null) kindCombo.SelectionChanged += OnKindChanged;
|
||||
|
||||
var shelveButton = this.FindControl<Button>("ShelveButton");
|
||||
if (shelveButton != null) shelveButton.Click += OnShelveClicked;
|
||||
|
||||
var cancelButton = this.FindControl<Button>("CancelButton");
|
||||
if (cancelButton != null) cancelButton.Click += OnCancelClicked;
|
||||
}
|
||||
|
||||
private void OnKindChanged(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
// Duration only applies to Timed (index 1).
|
||||
var durationPanel = this.FindControl<StackPanel>("DurationPanel");
|
||||
var kindCombo = this.FindControl<ComboBox>("KindCombo");
|
||||
if (durationPanel != null && kindCombo != null)
|
||||
durationPanel.IsEnabled = kindCombo.SelectedIndex == 1;
|
||||
}
|
||||
|
||||
private async void OnShelveClicked(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var kindCombo = this.FindControl<ComboBox>("KindCombo");
|
||||
var durationInput = this.FindControl<NumericUpDown>("DurationInput");
|
||||
var resultText = this.FindControl<TextBlock>("ResultText");
|
||||
if (kindCombo == null || durationInput == null || resultText == null) return;
|
||||
|
||||
var kind = kindCombo.SelectedIndex switch
|
||||
{
|
||||
1 => ShelveKind.Timed,
|
||||
2 => ShelveKind.Unshelve,
|
||||
_ => ShelveKind.OneShot
|
||||
};
|
||||
var duration = (double)(durationInput.Value ?? 0m);
|
||||
|
||||
resultText.Foreground = Brushes.Gray;
|
||||
resultText.Text = "Working...";
|
||||
|
||||
var (success, message) = await _alarmsVm.ShelveAlarmAsync(_alarm, kind, duration);
|
||||
if (success)
|
||||
{
|
||||
Close();
|
||||
}
|
||||
else
|
||||
{
|
||||
resultText.Foreground = Brushes.Red;
|
||||
resultText.Text = message;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnCancelClicked(object? sender, RoutedEventArgs e) => Close();
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Build** — `dotnet build src/Client/ZB.MOM.WW.OtOpcUa.Client.UI` → 0 errors (confirms the XAML compiles + the `NumericUpDown.Value` decimal→double cast is right).
|
||||
|
||||
**Step 4: Commit**
|
||||
```bash
|
||||
git add src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/Views/ShelveAlarmWindow.axaml \
|
||||
src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/Views/ShelveAlarmWindow.axaml.cs
|
||||
git commit -m "feat(client-ui): ShelveAlarmWindow dialog (kind + duration)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: `ConfirmAlarmWindow` dialog (view + code-behind)
|
||||
|
||||
**Classification:** small · **Est:** ~3 min · **Parallelizable with:** Task 3 · **blockedBy:** Task 2
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/Views/ConfirmAlarmWindow.axaml`
|
||||
- Create: `src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/Views/ConfirmAlarmWindow.axaml.cs`
|
||||
|
||||
**Context:** **Clone `AckAlarmWindow`** (comment box + result banner) → call `ConfirmAlarmAsync`. Only the title, the button text/handler, and the watermark differ.
|
||||
|
||||
**Step 1: `ConfirmAlarmWindow.axaml`** — identical to `AckAlarmWindow.axaml` except `x:Class=...ConfirmAlarmWindow`, `Title="Confirm Alarm"`, the header `TextBlock Text="Confirm Alarm"`, the comment `Watermark="Enter confirmation comment"`, and the button `Name="ConfirmButton" Content="Confirm"`.
|
||||
|
||||
**Step 2: `ConfirmAlarmWindow.axaml.cs`** — identical to `AckAlarmWindow.axaml.cs` except the class name, the button lookup `"ConfirmButton"`, and the call:
|
||||
```csharp
|
||||
var (success, message) = await _alarmsVm.ConfirmAlarmAsync(_alarm, comment);
|
||||
```
|
||||
(Keep the same `OnConfirmClicked`/`OnCancelClicked` shape, `resultText.Text = "Confirming...";`.)
|
||||
|
||||
**Step 3: Build** — `dotnet build src/Client/ZB.MOM.WW.OtOpcUa.Client.UI` → 0 errors.
|
||||
|
||||
**Step 4: Commit**
|
||||
```bash
|
||||
git add src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/Views/ConfirmAlarmWindow.axaml \
|
||||
src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/Views/ConfirmAlarmWindow.axaml.cs
|
||||
git commit -m "feat(client-ui): ConfirmAlarmWindow dialog (comment)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Extend the `AlarmsView` context menu (3 items + per-item enablement)
|
||||
|
||||
**Classification:** small · **Est:** ~5 min · **Parallelizable with:** none · **blockedBy:** Task 3, Task 4
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/Views/AlarmsView.axaml.cs`
|
||||
|
||||
**Context:** Today `OnLoaded` builds a 1-item menu ("Acknowledge...") and `OnContextMenuOpening` cancels the WHOLE menu when `!CanAcknowledge`. Rework to 3 items, enable each per its own predicate, and cancel only when none apply. Add `using System.Linq;`.
|
||||
|
||||
**Step 1: Replace `OnLoaded`'s menu build** (after `var contextMenu = new ContextMenu();`):
|
||||
```csharp
|
||||
var ackItem = new MenuItem { Header = "Acknowledge..." };
|
||||
ackItem.Click += OnAcknowledgeClicked;
|
||||
contextMenu.Items.Add(ackItem);
|
||||
|
||||
var shelveItem = new MenuItem { Header = "Shelve..." };
|
||||
shelveItem.Click += OnShelveClicked;
|
||||
contextMenu.Items.Add(shelveItem);
|
||||
|
||||
var confirmItem = new MenuItem { Header = "Confirm..." };
|
||||
confirmItem.Click += OnConfirmClicked;
|
||||
contextMenu.Items.Add(confirmItem);
|
||||
|
||||
contextMenu.Opening += OnContextMenuOpening;
|
||||
grid.ContextMenu = contextMenu;
|
||||
```
|
||||
|
||||
**Step 2: Replace `OnContextMenuOpening`** with per-item enablement:
|
||||
```csharp
|
||||
private void OnContextMenuOpening(object? sender, CancelEventArgs e)
|
||||
{
|
||||
var grid = this.FindControl<DataGrid>("AlarmsGrid");
|
||||
if (grid?.SelectedItem is not AlarmEventViewModel alarm)
|
||||
{
|
||||
e.Cancel = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (grid.ContextMenu is { } menu)
|
||||
{
|
||||
foreach (var item in menu.Items.OfType<MenuItem>())
|
||||
{
|
||||
item.IsEnabled = item.Header switch
|
||||
{
|
||||
"Acknowledge..." => alarm.CanAcknowledge,
|
||||
"Shelve..." => alarm.CanShelve,
|
||||
"Confirm..." => alarm.CanConfirm,
|
||||
_ => true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel the whole menu only when nothing is actionable for this row.
|
||||
if (!alarm.CanAcknowledge && !alarm.CanShelve && !alarm.CanConfirm)
|
||||
e.Cancel = true;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Add the two click handlers** (mirror `OnAcknowledgeClicked`):
|
||||
```csharp
|
||||
private void OnShelveClicked(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not AlarmsViewModel vm) return;
|
||||
var grid = this.FindControl<DataGrid>("AlarmsGrid");
|
||||
if (grid?.SelectedItem is not AlarmEventViewModel alarm || !alarm.CanShelve) return;
|
||||
var parentWindow = this.FindAncestorOfType<Window>();
|
||||
if (parentWindow == null) return;
|
||||
new ShelveAlarmWindow(vm, alarm).ShowDialog(parentWindow);
|
||||
}
|
||||
|
||||
private void OnConfirmClicked(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not AlarmsViewModel vm) return;
|
||||
var grid = this.FindControl<DataGrid>("AlarmsGrid");
|
||||
if (grid?.SelectedItem is not AlarmEventViewModel alarm || !alarm.CanConfirm) return;
|
||||
var parentWindow = this.FindAncestorOfType<Window>();
|
||||
if (parentWindow == null) return;
|
||||
new ConfirmAlarmWindow(vm, alarm).ShowDialog(parentWindow);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Build** — `dotnet build src/Client/ZB.MOM.WW.OtOpcUa.Client.UI` → 0 errors. Then full client tests: `dotnet test tests/Client/ZB.MOM.WW.OtOpcUa.Client.UI.Tests` → green.
|
||||
|
||||
**Step 5: Commit**
|
||||
```bash
|
||||
git add src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/Views/AlarmsView.axaml.cs
|
||||
git commit -m "feat(client-ui): Shelve/Confirm context-menu items with per-item enablement"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Docs + bookkeeping
|
||||
|
||||
**Classification:** small · **Est:** ~3 min · **Parallelizable with:** none · **blockedBy:** Task 5
|
||||
|
||||
**Files:**
|
||||
- Modify: the client UI doc (look for `docs/Client.UI.md`; if none, add a short "Alarm actions" note to the most relevant client doc — e.g. `docs/Client.CLI.md`'s sibling or `docs/ScriptedAlarms.md`'s client section). Document that the desktop client's Alarms tab now supports right-click **Acknowledge / Shelve (OneShot/Timed/Unshelve) / Confirm**, gated on alarm state, backed by the same `IOpcUaClientService` methods as the CLI.
|
||||
|
||||
**Step 1:** Find the doc (`ls docs/ | grep -i client`); add the note. Keep it short + accurate (Shelve has no comment; Confirm needs a prior Ack).
|
||||
|
||||
**Step 2: Commit**
|
||||
```bash
|
||||
git add docs/<the-doc>.md
|
||||
git commit -m "docs(phase7): client desktop alarm Ack/Shelve/Confirm actions"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Full build + test + final integration review
|
||||
|
||||
**Classification:** standard · **Est:** ~4 min · **Parallelizable with:** none · **blockedBy:** Task 1–6
|
||||
|
||||
**Steps:**
|
||||
- `dotnet build ZB.MOM.WW.OtOpcUa.slnx` → 0 errors.
|
||||
- `dotnet test tests/Client/ZB.MOM.WW.OtOpcUa.Client.UI.Tests` → green (all new VM/predicate tests + no regressions).
|
||||
- Final integration review of the cumulative branch diff (`git diff ad3ec9d9..HEAD`): (a) the 2 VM methods mirror `AcknowledgeAlarmAsync` (guards, `StatusCode.IsGood`, formatter, try/catch); (b) `CanShelve`/`CanConfirm` match the design; (c) the context-menu cancels only when nothing is actionable + each item's `IsEnabled` is correct; (d) the dialogs wire to the right VM method + close on success / show Bad on failure; (e) NO `IOpcUaClientService`/CLI/Commons/EF change leaked. Fix findings, commit.
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Live launch/connect smoke (agent-driven, honest)
|
||||
|
||||
**Classification:** small · **Est:** ~5 min · **Parallelizable with:** none · **blockedBy:** Task 7
|
||||
|
||||
**Context:** The Avalonia desktop GUI is **not agent-drivable** (no display; the Chrome tools are web-only). So this is a build + launch/connect smoke, NOT a click-through.
|
||||
|
||||
**Steps:**
|
||||
- Confirm the rig server is up: `curl -s -o /dev/null -w "%{http_code}" http://localhost:9200/` (AdminUI) and that central-1 is serving OPC UA on `:4840` (it carries 1 scripted alarm). If down: `docker compose -f docker-dev/docker-compose.yml up -d central-1` (use `dangerouslyDisableSandbox: true` for any rig network call).
|
||||
- Build + launch the client headlessly to confirm it starts without crashing: `dotnet build src/Client/ZB.MOM.WW.OtOpcUa.Client.UI` (0 errors). A full GUI run (`dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.UI`) needs a display; if it can't render headlessly, record that the **click-through is operator-gated** and rely on the unit-proven VM→service round-trip + the clean build.
|
||||
- Record results honestly in the tasks.json (verified: build + tests + VM round-trip unit-proven; operator-gated: the actual button click-through, since Avalonia native isn't agent-drivable).
|
||||
|
||||
---
|
||||
|
||||
## Done =
|
||||
Build clean + `Client.UI.Tests` green + final integration review SHIP + launch/connect smoke recorded. Then `finishing-a-development-branch` → merge to master + push (standing directive). Update the `project_stillpending_backlog` memory (Phase 7 shipped; §4 Client.UI gaps closed).
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-06-16-stillpending-phase-7-client-alarm-buttons.md",
|
||||
"branch": "feat/stillpending-phase-7-client-alarm-buttons",
|
||||
"baseMaster": "ad3ec9d9",
|
||||
"designCommit": "573728b5",
|
||||
"executionState": "PLANNED — not started",
|
||||
"tasks": [
|
||||
{"id": 468, "subject": "P7 Task 1: Extend FakeOpcUaClientService (Shelve/Confirm tracking)", "status": "pending"},
|
||||
{"id": 469, "subject": "P7 Task 2: AlarmsViewModel Shelve/Confirm + CanShelve/CanConfirm + tests", "status": "pending", "blockedBy": [468]},
|
||||
{"id": 470, "subject": "P7 Task 3: ShelveAlarmWindow dialog (kind + duration)", "status": "pending", "blockedBy": [469]},
|
||||
{"id": 471, "subject": "P7 Task 4: ConfirmAlarmWindow dialog (comment)", "status": "pending", "blockedBy": [469]},
|
||||
{"id": 472, "subject": "P7 Task 5: AlarmsView context-menu (3 items + per-item enablement)", "status": "pending", "blockedBy": [470, 471]},
|
||||
{"id": 473, "subject": "P7 Task 6: Docs + bookkeeping", "status": "pending", "blockedBy": [472]},
|
||||
{"id": 474, "subject": "P7 Task 7: Full build + test + final integration review", "status": "pending", "blockedBy": [468, 469, 470, 471, 472, 473]},
|
||||
{"id": 475, "subject": "P7 Task 8: Live launch/connect smoke (operator-gated click-through)", "status": "pending", "blockedBy": [474]}
|
||||
],
|
||||
"notes": "UI surface = extend context menu; Confirm gating = simple (AckedState, no ConfirmedState tracking). No IOpcUaClientService/CLI/Commons/EF change. Avalonia GUI not agent-drivable → live click-through operator-gated.",
|
||||
"lastUpdated": "2026-06-16"
|
||||
}
|
||||
Reference in New Issue
Block a user