From 253847323ae10e4cd50b61539548913ccd523c7b Mon Sep 17 00:00:00 2001 From: doyaGu Date: Sat, 3 Oct 2026 22:46:09 -0400 Subject: [PATCH] fix(board): skip the refresh when the card editor wrote nothing Every editor close rebuilt all columns, so opening a card and cancelling reset each column's scroll position. The editor now reports whether it saved the card or created a tag in the nested picker, and the board only refreshes in that case. --- .../ViewModels/BoardViewModelTests.cs | 60 +++++++++++++++++++ YKanBan/ViewModels/Board/BoardViewModel.cs | 17 ++++-- .../Dialogs/CardEditorDialogViewModel.cs | 13 +++- .../Dialogs/TagPickerDialogViewModel.cs | 6 ++ docs/ACCEPTANCE.md | 1 + 5 files changed, 91 insertions(+), 6 deletions(-) diff --git a/YKanBan.Tests/ViewModels/BoardViewModelTests.cs b/YKanBan.Tests/ViewModels/BoardViewModelTests.cs index 8acc66e..66ae428 100644 --- a/YKanBan.Tests/ViewModels/BoardViewModelTests.cs +++ b/YKanBan.Tests/ViewModels/BoardViewModelTests.cs @@ -204,6 +204,66 @@ public class BoardViewModelTests Assert.AreEqual(card.Id, board.Columns[0].Cards.Single().Card.Id); } + [TestMethod] + public async Task EditorThatWroteNothingLeavesTheColumnsAlone() + { + using var fixture = new Fixture(); + ColumnModel column = fixture.Repository.AddColumn("A", ""); + fixture.Repository.AddCard(column.Id, "t", "", []); + BoardViewModel board = fixture.CreateBoard(); + ColumnViewModel shown = board.Columns.Single(); + int writes = 0; + board.DataWritten += (_, _) => writes++; + + // Cancel a new card, confirm an unchanged existing card, confirm an empty new card. + fixture.Dialogs.Script = dialog => dialog is CardEditorDialogViewModel editor + ? editor.RequestCancelAsync() + : Task.CompletedTask; + await shown.AddCardCommand.ExecuteAsync(null); + fixture.Dialogs.Script = dialog => + { + (dialog as CardEditorDialogViewModel)?.ConfirmCommand.Execute(null); + return Task.CompletedTask; + }; + await shown.Cards.Single().EditCommand.ExecuteAsync(null); + await shown.AddCardCommand.ExecuteAsync(null); + + Assert.AreSame(shown, board.Columns.Single()); + Assert.AreEqual(0, writes); + } + + [TestMethod] + public async Task TagCreatedInACancelledEditRefreshesTheBoard() + { + using var fixture = new Fixture(); + fixture.Repository.AddColumn("A", ""); + BoardViewModel board = fixture.CreateBoard(); + ColumnViewModel shown = board.Columns.Single(); + int writes = 0; + board.DataWritten += (_, _) => writes++; + fixture.Dialogs.Script = async dialog => + { + switch (dialog) + { + case CardEditorDialogViewModel editor: + await editor.AddTagCommand.ExecuteAsync(null); + editor.Tags.Clear(); + await editor.RequestCancelAsync(); + break; + case TagPickerDialogViewModel picker: + picker.NewName = "fresh"; + picker.NewColor = "#123456"; + picker.CreateCommand.Execute(null); + break; + } + }; + + await shown.AddCardCommand.ExecuteAsync(null); + + Assert.AreNotSame(shown, board.Columns.Single()); + Assert.AreEqual(1, writes); + } + [TestMethod] public void UnchangedEditConfirmationWritesNothing() { diff --git a/YKanBan/ViewModels/Board/BoardViewModel.cs b/YKanBan/ViewModels/Board/BoardViewModel.cs index 2331715..d728a8f 100644 --- a/YKanBan/ViewModels/Board/BoardViewModel.cs +++ b/YKanBan/ViewModels/Board/BoardViewModel.cs @@ -274,9 +274,7 @@ public sealed partial class BoardViewModel : ViewModelBase var editor = CardEditorDialogViewModel.ForNewCard( _repository, _dialogs, columnId, _config.Confirmations.DiscardEdit); await _dialogs.ShowAsync(editor); - - // Refresh even after a cancel: the nested picker may have created tags. - await RefreshAfterWriteAsync(); + await RefreshAfterEditorAsync(editor); } /// @@ -306,9 +304,20 @@ public sealed partial class BoardViewModel : ViewModelBase var editor = CardEditorDialogViewModel.ForExistingCard( _repository, _dialogs, card, _config.Confirmations.DiscardEdit); await _dialogs.ShowAsync(editor); - await RefreshAfterWriteAsync(); + await RefreshAfterEditorAsync(editor); } + /// + /// Refreshes after the card editor closes, but only when it wrote something: + /// a saved card, or a tag created in the nested picker even if the edit was + /// cancelled. An editor that wrote nothing leaves the columns, and so their + /// scroll positions, untouched. + /// + /// The closed editor. + /// A task completing after the refresh, if any. + private Task RefreshAfterEditorAsync(CardEditorDialogViewModel editor) => + editor.WroteToWorkspace ? RefreshAfterWriteAsync() : Task.CompletedTask; + /// /// Deletes a card, asking first when that confirmation is enabled. /// diff --git a/YKanBan/ViewModels/Dialogs/CardEditorDialogViewModel.cs b/YKanBan/ViewModels/Dialogs/CardEditorDialogViewModel.cs index 562120b..140f219 100644 --- a/YKanBan/ViewModels/Dialogs/CardEditorDialogViewModel.cs +++ b/YKanBan/ViewModels/Dialogs/CardEditorDialogViewModel.cs @@ -118,6 +118,12 @@ public sealed partial class CardEditorDialogViewModel : DialogViewModelBase /// public CardModel? SavedCard { get; private set; } + /// + /// Gets whether this edit session wrote anything: the card on confirmation, + /// or a tag created in the nested picker (kept even when the edit is cancelled). + /// + public bool WroteToWorkspace { get; private set; } + /// /// Gets or sets the card title; the empty string means "no title". /// @@ -169,9 +175,11 @@ public sealed partial class CardEditorDialogViewModel : DialogViewModelBase return; } - if (await _dialogs.ShowAsync(picker) && picker.PickedTag is { } picked) + bool picked = await _dialogs.ShowAsync(picker); + WroteToWorkspace |= picker.CreatedTag; + if (picked && picker.PickedTag is { } tag) { - Tags.Add(new TagBadgeViewModel(picked)); + Tags.Add(new TagBadgeViewModel(tag)); } } @@ -202,6 +210,7 @@ public sealed partial class CardEditorDialogViewModel : DialogViewModelBase SavedCard = _existing is null ? _repository.AddCard(_columnId, CardTitle, Content, tagIds) : _repository.UpdateCard(_existing.Id, CardTitle, Content, tagIds); + WroteToWorkspace = true; } catch (Exception exception) { diff --git a/YKanBan/ViewModels/Dialogs/TagPickerDialogViewModel.cs b/YKanBan/ViewModels/Dialogs/TagPickerDialogViewModel.cs index f500f75..3e833d5 100644 --- a/YKanBan/ViewModels/Dialogs/TagPickerDialogViewModel.cs +++ b/YKanBan/ViewModels/Dialogs/TagPickerDialogViewModel.cs @@ -48,6 +48,11 @@ public sealed partial class TagPickerDialogViewModel : DialogViewModelBase /// public TagModel? PickedTag { get; private set; } + /// + /// Gets whether was newly created, i.e. written to the workspace. + /// + public bool CreatedTag { get; private set; } + /// /// Gets the available tags matching . /// @@ -127,6 +132,7 @@ public sealed partial class TagPickerDialogViewModel : DialogViewModelBase try { PickedTag = _repository.AddTag(NewName, color, NewDescription); + CreatedTag = true; } catch (Exception exception) { diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 7bb0fba..895bcc7 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -75,6 +75,7 @@ - [ ] 列宽:窄 240 / 标准 320 / 宽 480 / 手动 160–720;全局生效,重启保持 - [ ] 排序:id 升序 / 最后修改时间降序 / 标题升序(空标题在前);只影响卡片,列始终 id 序;重启保持 - [ ] 大数据量:用脚本向一列灌入约 10000 张卡片,滚动流畅、打开工作区无明显卡顿(虚拟化) +- [ ] 长列滚动到中部后打开某张卡片,不改动就取消或确认 → 各列滚动位置不变(未写库不刷新看板) - [ ] 所有模态在父窗口居中;使用系统原生标题栏 - [ ] 打开带输入框的模态时,第一个输入框自动获得焦点并全选