feat: implement tags tab, board search execution and markdown export wiring
This commit is contained in:
1 parent
e3f58ce150
commit
6ea0df43e3
22 files changed
+1300
-89
No files matched your search
@@ -0,0 +1,50 @@
|
|||||||
|
using YKanBan.ViewModels.Dialogs;
|
||||||
|
|
||||||
|
namespace YKanBan.Tests.TestUtilities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records every dialog shown and lets a test script answer it. The result
|
||||||
|
/// is whatever the dialog closed with; a dialog the script leaves open
|
||||||
|
/// counts as dismissed. The save-file dialog answers with
|
||||||
|
/// <see cref="SaveFilePath"/>.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class ScriptedDialogService : IDialogService
|
||||||
|
{
|
||||||
|
public List<DialogViewModelBase> Shown { get; } = [];
|
||||||
|
|
||||||
|
public Func<DialogViewModelBase, Task> Script { get; set; } = _ => Task.CompletedTask;
|
||||||
|
|
||||||
|
public string? SaveFilePath { get; set; }
|
||||||
|
|
||||||
|
public List<string> SuggestedFileNames { get; } = [];
|
||||||
|
|
||||||
|
public async Task<bool> ShowAsync(DialogViewModelBase dialog)
|
||||||
|
{
|
||||||
|
Shown.Add(dialog);
|
||||||
|
bool? result = null;
|
||||||
|
dialog.CloseRequested += (_, closed) => result ??= closed;
|
||||||
|
await Script(dialog);
|
||||||
|
return result ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<string?> PickSaveFilePathAsync(string suggestedFileName)
|
||||||
|
{
|
||||||
|
SuggestedFileNames.Add(suggestedFileName);
|
||||||
|
return Task.FromResult(SaveFilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Answers every confirmation dialog with the given choice.</summary>
|
||||||
|
public static Func<DialogViewModelBase, Task> AnswerConfirmations(bool confirm) => dialog =>
|
||||||
|
{
|
||||||
|
if (dialog is ConfirmDialogViewModel confirmation)
|
||||||
|
{
|
||||||
|
if (confirm)
|
||||||
|
{
|
||||||
|
confirmation.ConfirmCommand.Execute(null);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
return confirmation.CancelCommand.ExecuteAsync(null);
|
||||||
|
}
|
||||||
|
return Task.CompletedTask;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -16,42 +16,6 @@ namespace YKanBan.Tests.ViewModels;
|
|||||||
[TestClass]
|
[TestClass]
|
||||||
public class BoardViewModelTests
|
public class BoardViewModelTests
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// Records every dialog shown and lets a test script answer it. The result
|
|
||||||
/// is whatever the dialog closed with; a dialog the script leaves open
|
|
||||||
/// counts as dismissed.
|
|
||||||
/// </summary>
|
|
||||||
private sealed class ScriptedDialogService : IDialogService
|
|
||||||
{
|
|
||||||
public List<DialogViewModelBase> Shown { get; } = [];
|
|
||||||
|
|
||||||
public Func<DialogViewModelBase, Task> Script { get; set; } = _ => Task.CompletedTask;
|
|
||||||
|
|
||||||
public async Task<bool> ShowAsync(DialogViewModelBase dialog)
|
|
||||||
{
|
|
||||||
Shown.Add(dialog);
|
|
||||||
bool? result = null;
|
|
||||||
dialog.CloseRequested += (_, closed) => result ??= closed;
|
|
||||||
await Script(dialog);
|
|
||||||
return result ?? false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Answers every confirmation dialog with the given choice.</summary>
|
|
||||||
private static Func<DialogViewModelBase, Task> AnswerConfirmations(bool confirm) => dialog =>
|
|
||||||
{
|
|
||||||
if (dialog is ConfirmDialogViewModel confirmation)
|
|
||||||
{
|
|
||||||
if (confirm)
|
|
||||||
{
|
|
||||||
confirmation.ConfirmCommand.Execute(null);
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
return confirmation.CancelCommand.ExecuteAsync(null);
|
|
||||||
}
|
|
||||||
return Task.CompletedTask;
|
|
||||||
};
|
|
||||||
|
|
||||||
private sealed class Fixture : IDisposable
|
private sealed class Fixture : IDisposable
|
||||||
{
|
{
|
||||||
public TempDirectory Directory { get; } = new();
|
public TempDirectory Directory { get; } = new();
|
||||||
@@ -144,7 +108,7 @@ public class BoardViewModelTests
|
|||||||
fixture.Repository.AddCard(column.Id, "", "", []);
|
fixture.Repository.AddCard(column.Id, "", "", []);
|
||||||
fixture.Repository.AddCard(column.Id, "", "", []);
|
fixture.Repository.AddCard(column.Id, "", "", []);
|
||||||
BoardViewModel board = fixture.CreateBoard();
|
BoardViewModel board = fixture.CreateBoard();
|
||||||
fixture.Dialogs.Script = AnswerConfirmations(false);
|
fixture.Dialogs.Script = ScriptedDialogService.AnswerConfirmations(false);
|
||||||
|
|
||||||
await board.Columns[0].DeleteCommand.ExecuteAsync(null);
|
await board.Columns[0].DeleteCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
@@ -174,7 +138,7 @@ public class BoardViewModelTests
|
|||||||
ColumnModel column = fixture.Repository.AddColumn("A", "");
|
ColumnModel column = fixture.Repository.AddColumn("A", "");
|
||||||
fixture.Repository.AddCard(column.Id, "gone", "", []);
|
fixture.Repository.AddCard(column.Id, "gone", "", []);
|
||||||
BoardViewModel board = fixture.CreateBoard();
|
BoardViewModel board = fixture.CreateBoard();
|
||||||
fixture.Dialogs.Script = AnswerConfirmations(true);
|
fixture.Dialogs.Script = ScriptedDialogService.AnswerConfirmations(true);
|
||||||
|
|
||||||
await board.Columns[0].Cards[0].DeleteCommand.ExecuteAsync(null);
|
await board.Columns[0].Cards[0].DeleteCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
@@ -271,11 +235,11 @@ public class BoardViewModelTests
|
|||||||
editor.CloseRequested += (_, result) => closedWith = result;
|
editor.CloseRequested += (_, result) => closedWith = result;
|
||||||
editor.Content = "draft";
|
editor.Content = "draft";
|
||||||
|
|
||||||
fixture.Dialogs.Script = AnswerConfirmations(false);
|
fixture.Dialogs.Script = ScriptedDialogService.AnswerConfirmations(false);
|
||||||
await editor.RequestCancelAsync();
|
await editor.RequestCancelAsync();
|
||||||
Assert.IsNull(closedWith, "Declining the discard keeps the editor open.");
|
Assert.IsNull(closedWith, "Declining the discard keeps the editor open.");
|
||||||
|
|
||||||
fixture.Dialogs.Script = AnswerConfirmations(true);
|
fixture.Dialogs.Script = ScriptedDialogService.AnswerConfirmations(true);
|
||||||
await editor.RequestCancelAsync();
|
await editor.RequestCancelAsync();
|
||||||
Assert.IsFalse(closedWith);
|
Assert.IsFalse(closedWith);
|
||||||
Assert.AreEqual(0, fixture.Repository.GetCards().Count);
|
Assert.AreEqual(0, fixture.Repository.GetCards().Count);
|
||||||
|
|||||||
@@ -0,0 +1,400 @@
|
|||||||
|
using YKanBan.Models;
|
||||||
|
using YKanBan.Storage.AppData;
|
||||||
|
using YKanBan.Storage.Workspace;
|
||||||
|
using YKanBan.Tests.TestUtilities;
|
||||||
|
using YKanBan.ViewModels;
|
||||||
|
using YKanBan.ViewModels.Board;
|
||||||
|
using YKanBan.ViewModels.Dialogs;
|
||||||
|
using YKanBan.ViewModels.Tags;
|
||||||
|
|
||||||
|
namespace YKanBan.Tests.ViewModels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// View-model tests of the search bar, the tags tab, the cross-tab refresh and
|
||||||
|
/// the export wiring, against a real temporary workspace with a scripted
|
||||||
|
/// dialog service.
|
||||||
|
/// </summary>
|
||||||
|
[TestClass]
|
||||||
|
public class TagsAndSearchViewModelTests
|
||||||
|
{
|
||||||
|
private sealed class Fixture : IDisposable
|
||||||
|
{
|
||||||
|
public TempDirectory Directory { get; } = new();
|
||||||
|
|
||||||
|
public WorkspaceSession Session { get; }
|
||||||
|
|
||||||
|
public WorkspaceRepository Repository => Session.Repository;
|
||||||
|
|
||||||
|
public AppConfig Config { get; } = new();
|
||||||
|
|
||||||
|
public ScriptedDialogService Dialogs { get; } = new();
|
||||||
|
|
||||||
|
public Fixture()
|
||||||
|
{
|
||||||
|
WorkspaceInitializer.Initialize(Directory.FullPath);
|
||||||
|
Session = WorkspaceSession.Open(Directory.FullPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public BoardViewModel CreateBoard() => new(Repository, Config, Dialogs);
|
||||||
|
|
||||||
|
public TagsViewModel CreateTags() => new(Repository, Config, Dialogs);
|
||||||
|
|
||||||
|
public WorkspaceViewModel CreateWorkspace() =>
|
||||||
|
new(Session, new Interaction<Unit>(), new Interaction<Unit>(), Config, Dialogs);
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Session.Dispose();
|
||||||
|
Directory.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string[] VisibleTitles(BoardViewModel board) =>
|
||||||
|
board.Columns.SelectMany(column => column.Cards).Select(card => card.Title).Order(StringComparer.Ordinal).ToArray();
|
||||||
|
|
||||||
|
/// <summary>Moves a visible card through its menu command.</summary>
|
||||||
|
private static Task MoveAsync(BoardViewModel board, long cardId, long columnId) =>
|
||||||
|
board.Columns.SelectMany(column => column.Cards).Single(card => card.Card.Id == cardId)
|
||||||
|
.MoveToCommand.ExecuteAsync(columnId);
|
||||||
|
|
||||||
|
#region Search
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public async Task SearchFiltersCardsAndKeepsEmptyColumns()
|
||||||
|
{
|
||||||
|
using var fixture = new Fixture();
|
||||||
|
ColumnModel first = fixture.Repository.AddColumn("A", "");
|
||||||
|
ColumnModel second = fixture.Repository.AddColumn("B", "");
|
||||||
|
TagModel bug = fixture.Repository.AddTag("bug", new RgbColor(1, 2, 3), "");
|
||||||
|
fixture.Repository.AddCard(first.Id, "crash", "", [bug.Id]);
|
||||||
|
fixture.Repository.AddCard(second.Id, "idea", "", []);
|
||||||
|
BoardViewModel board = fixture.CreateBoard();
|
||||||
|
|
||||||
|
board.SearchText = "tag:BUG";
|
||||||
|
await board.SearchCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
CollectionAssert.AreEqual(new[] { "crash" }, VisibleTitles(board));
|
||||||
|
Assert.AreEqual(2, board.Columns.Count);
|
||||||
|
Assert.AreEqual(0, board.Columns[1].CardCount);
|
||||||
|
Assert.IsNotNull(board.ActiveFilter);
|
||||||
|
Assert.AreEqual(0, fixture.Dialogs.Shown.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public async Task InvalidSearchShowsTheErrorAndKeepsTextAndPreviousResult()
|
||||||
|
{
|
||||||
|
using var fixture = new Fixture();
|
||||||
|
ColumnModel column = fixture.Repository.AddColumn("A", "");
|
||||||
|
fixture.Repository.AddCard(column.Id, "alpha", "", []);
|
||||||
|
fixture.Repository.AddCard(column.Id, "beta", "", []);
|
||||||
|
BoardViewModel board = fixture.CreateBoard();
|
||||||
|
board.SearchText = "alpha";
|
||||||
|
await board.SearchCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
board.SearchText = "alpha AND";
|
||||||
|
await board.SearchCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
var error = (MessageDialogViewModel)fixture.Dialogs.Shown.Single();
|
||||||
|
Assert.AreEqual(Resources.Search_Error_Title, error.Title);
|
||||||
|
Assert.AreEqual("alpha AND", board.SearchText);
|
||||||
|
CollectionAssert.AreEqual(new[] { "alpha" }, VisibleTitles(board));
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public async Task WritesRefreshWithTheLastSuccessfulExpressionNotTheBoxText()
|
||||||
|
{
|
||||||
|
using var fixture = new Fixture();
|
||||||
|
ColumnModel first = fixture.Repository.AddColumn("A", "");
|
||||||
|
ColumnModel second = fixture.Repository.AddColumn("B", "");
|
||||||
|
CardModel alpha = fixture.Repository.AddCard(first.Id, "alpha", "", []);
|
||||||
|
fixture.Repository.AddCard(first.Id, "beta", "", []);
|
||||||
|
BoardViewModel board = fixture.CreateBoard();
|
||||||
|
board.SearchText = "alpha";
|
||||||
|
await board.SearchCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
board.SearchText = "beta";
|
||||||
|
await MoveAsync(board, alpha.Id, second.Id);
|
||||||
|
|
||||||
|
CollectionAssert.AreEqual(new[] { "alpha" }, VisibleTitles(board));
|
||||||
|
Assert.AreEqual(1, board.Columns[1].CardCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public async Task BlankSearchAndClearShowEveryCard()
|
||||||
|
{
|
||||||
|
using var fixture = new Fixture();
|
||||||
|
ColumnModel column = fixture.Repository.AddColumn("A", "");
|
||||||
|
fixture.Repository.AddCard(column.Id, "alpha", "", []);
|
||||||
|
fixture.Repository.AddCard(column.Id, "beta", "", []);
|
||||||
|
BoardViewModel board = fixture.CreateBoard();
|
||||||
|
|
||||||
|
board.SearchText = "alpha";
|
||||||
|
await board.SearchCommand.ExecuteAsync(null);
|
||||||
|
board.SearchText = " ";
|
||||||
|
await board.SearchCommand.ExecuteAsync(null);
|
||||||
|
Assert.IsNull(board.ActiveFilter);
|
||||||
|
CollectionAssert.AreEqual(new[] { "alpha", "beta" }, VisibleTitles(board));
|
||||||
|
|
||||||
|
board.SearchText = "alpha";
|
||||||
|
await board.SearchCommand.ExecuteAsync(null);
|
||||||
|
Assert.IsTrue(board.HasSearchText);
|
||||||
|
board.ClearSearchCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.AreEqual("", board.SearchText);
|
||||||
|
Assert.IsFalse(board.HasSearchText);
|
||||||
|
Assert.IsNull(board.ActiveFilter);
|
||||||
|
CollectionAssert.AreEqual(new[] { "alpha", "beta" }, VisibleTitles(board));
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public async Task DataWrittenIsRaisedForWritesButNotForSearchOrSort()
|
||||||
|
{
|
||||||
|
using var fixture = new Fixture();
|
||||||
|
ColumnModel first = fixture.Repository.AddColumn("A", "");
|
||||||
|
ColumnModel second = fixture.Repository.AddColumn("B", "");
|
||||||
|
CardModel card = fixture.Repository.AddCard(first.Id, "x", "", []);
|
||||||
|
BoardViewModel board = fixture.CreateBoard();
|
||||||
|
int raised = 0;
|
||||||
|
board.DataWritten += (_, _) => raised++;
|
||||||
|
|
||||||
|
board.SearchText = "x";
|
||||||
|
await board.SearchCommand.ExecuteAsync(null);
|
||||||
|
board.SelectedSort = board.SortOptions[1];
|
||||||
|
Assert.AreEqual(0, raised);
|
||||||
|
|
||||||
|
await MoveAsync(board, card.Id, second.Id);
|
||||||
|
Assert.AreEqual(1, raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Tags tab
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void TagsListInIdOrderWithUsageCounts()
|
||||||
|
{
|
||||||
|
using var fixture = new Fixture();
|
||||||
|
ColumnModel column = fixture.Repository.AddColumn("A", "");
|
||||||
|
TagModel bug = fixture.Repository.AddTag("bug", new RgbColor(1, 2, 3), "Broken");
|
||||||
|
TagModel unused = fixture.Repository.AddTag("unused", new RgbColor(4, 5, 6), "");
|
||||||
|
fixture.Repository.AddCard(column.Id, "a", "", [bug.Id]);
|
||||||
|
fixture.Repository.AddCard(column.Id, "b", "", [bug.Id]);
|
||||||
|
|
||||||
|
TagsViewModel tags = fixture.CreateTags();
|
||||||
|
|
||||||
|
CollectionAssert.AreEqual(new[] { bug.Id, unused.Id }, tags.Tags.Select(row => row.Tag.Id).ToArray());
|
||||||
|
CollectionAssert.AreEqual(new long[] { 2, 0 }, tags.Tags.Select(row => row.UsageCount).ToArray());
|
||||||
|
Assert.IsTrue(tags.Tags[0].HasDescription);
|
||||||
|
Assert.AreEqual("#010203", tags.Tags[0].ColorText);
|
||||||
|
Assert.IsFalse(tags.IsEmpty);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public async Task NewTagIsWrittenAndAnnounced()
|
||||||
|
{
|
||||||
|
using var fixture = new Fixture();
|
||||||
|
TagsViewModel tags = fixture.CreateTags();
|
||||||
|
int changed = 0;
|
||||||
|
tags.TagsChanged += (_, _) => changed++;
|
||||||
|
fixture.Dialogs.Script = dialog =>
|
||||||
|
{
|
||||||
|
var editor = (TagEditorDialogViewModel)dialog;
|
||||||
|
editor.Name = "ui";
|
||||||
|
editor.PickSwatchCommand.Execute(TagColors.Palette[2].ToString());
|
||||||
|
editor.Description = "Looks";
|
||||||
|
editor.ConfirmCommand.Execute(null);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
};
|
||||||
|
|
||||||
|
await tags.AddTagCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
TagModel created = fixture.Repository.GetTags().Single();
|
||||||
|
Assert.AreEqual("ui", created.Name);
|
||||||
|
Assert.AreEqual(TagColors.Palette[2], created.Color);
|
||||||
|
Assert.AreEqual("Looks", created.Description);
|
||||||
|
Assert.AreEqual(1, tags.Tags.Count);
|
||||||
|
Assert.AreEqual(1, changed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public async Task TagEditorKeepsTheDialogOpenOnDuplicateNameOrInvalidColor()
|
||||||
|
{
|
||||||
|
using var fixture = new Fixture();
|
||||||
|
fixture.Repository.AddTag("Bug", new RgbColor(1, 2, 3), "");
|
||||||
|
TagsViewModel tags = fixture.CreateTags();
|
||||||
|
TagEditorDialogViewModel? editor = null;
|
||||||
|
bool closed = false;
|
||||||
|
fixture.Dialogs.Script = dialog =>
|
||||||
|
{
|
||||||
|
editor = (TagEditorDialogViewModel)dialog;
|
||||||
|
editor.CloseRequested += (_, _) => closed = true;
|
||||||
|
editor.Name = "bug";
|
||||||
|
editor.ConfirmCommand.Execute(null);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
};
|
||||||
|
|
||||||
|
await tags.AddTagCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
Assert.IsNotNull(editor);
|
||||||
|
Assert.IsTrue(editor.HasError);
|
||||||
|
Assert.IsFalse(closed);
|
||||||
|
Assert.AreEqual("bug", editor.Name);
|
||||||
|
|
||||||
|
editor.Name = "other";
|
||||||
|
editor.Color = "red";
|
||||||
|
editor.ConfirmCommand.Execute(null);
|
||||||
|
Assert.AreEqual(Resources.Error_Tag_InvalidColor, editor.ErrorMessage);
|
||||||
|
Assert.IsFalse(closed);
|
||||||
|
Assert.AreEqual(1, fixture.Repository.GetTags().Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public async Task EditingATagRenamesRecolorsAndDescribesIt()
|
||||||
|
{
|
||||||
|
using var fixture = new Fixture();
|
||||||
|
TagModel tag = fixture.Repository.AddTag("bug", new RgbColor(1, 2, 3), "");
|
||||||
|
TagsViewModel tags = fixture.CreateTags();
|
||||||
|
fixture.Dialogs.Script = dialog =>
|
||||||
|
{
|
||||||
|
var editor = (TagEditorDialogViewModel)dialog;
|
||||||
|
Assert.AreEqual("bug", editor.Name);
|
||||||
|
Assert.AreEqual("#010203", editor.Color);
|
||||||
|
editor.Name = "defect";
|
||||||
|
editor.Color = "#ABCDEF";
|
||||||
|
editor.Description = "Broken";
|
||||||
|
editor.ConfirmCommand.Execute(null);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
};
|
||||||
|
|
||||||
|
await tags.Tags[0].EditCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
TagModel updated = fixture.Repository.GetTags().Single();
|
||||||
|
Assert.AreEqual(tag.Id, updated.Id);
|
||||||
|
Assert.AreEqual("defect", updated.Name);
|
||||||
|
Assert.AreEqual(new RgbColor(0xab, 0xcd, 0xef), updated.Color);
|
||||||
|
Assert.AreEqual("Broken", updated.Description);
|
||||||
|
Assert.AreEqual("defect", tags.Tags[0].Badge.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public async Task DeletingATagAsksFirstAndKeepsTheCards()
|
||||||
|
{
|
||||||
|
using var fixture = new Fixture();
|
||||||
|
ColumnModel column = fixture.Repository.AddColumn("A", "");
|
||||||
|
TagModel bug = fixture.Repository.AddTag("bug", new RgbColor(1, 2, 3), "");
|
||||||
|
CardModel card = fixture.Repository.AddCard(column.Id, "a", "", [bug.Id]);
|
||||||
|
TagsViewModel tags = fixture.CreateTags();
|
||||||
|
|
||||||
|
fixture.Dialogs.Script = ScriptedDialogService.AnswerConfirmations(false);
|
||||||
|
await tags.Tags[0].DeleteCommand.ExecuteAsync(null);
|
||||||
|
Assert.AreEqual(1, fixture.Repository.GetTags().Count);
|
||||||
|
|
||||||
|
fixture.Dialogs.Script = ScriptedDialogService.AnswerConfirmations(true);
|
||||||
|
await tags.Tags[0].DeleteCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
Assert.AreEqual(0, fixture.Repository.GetTags().Count);
|
||||||
|
Assert.IsTrue(tags.IsEmpty);
|
||||||
|
CardModel kept = fixture.Repository.GetCard(card.Id)!;
|
||||||
|
Assert.AreEqual(0, kept.Tags.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public async Task DeletingATagWithoutConfirmationShowsNoDialog()
|
||||||
|
{
|
||||||
|
using var fixture = new Fixture();
|
||||||
|
fixture.Repository.AddTag("bug", new RgbColor(1, 2, 3), "");
|
||||||
|
fixture.Config.Confirmations.DeleteTag = false;
|
||||||
|
TagsViewModel tags = fixture.CreateTags();
|
||||||
|
|
||||||
|
await tags.Tags[0].DeleteCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
Assert.AreEqual(0, fixture.Dialogs.Shown.Count);
|
||||||
|
Assert.AreEqual(0, fixture.Repository.GetTags().Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Workspace wiring and export
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public async Task TagWritesRefreshTheBoardAndBoardWritesRefreshTheTags()
|
||||||
|
{
|
||||||
|
using var fixture = new Fixture();
|
||||||
|
ColumnModel first = fixture.Repository.AddColumn("A", "");
|
||||||
|
ColumnModel second = fixture.Repository.AddColumn("B", "");
|
||||||
|
TagModel bug = fixture.Repository.AddTag("bug", new RgbColor(1, 2, 3), "");
|
||||||
|
CardModel card = fixture.Repository.AddCard(first.Id, "a", "", [bug.Id]);
|
||||||
|
WorkspaceViewModel workspace = fixture.CreateWorkspace();
|
||||||
|
|
||||||
|
// A filtered board drops the card once the tag is gone.
|
||||||
|
workspace.Board.SearchText = "tag:bug";
|
||||||
|
await workspace.Board.SearchCommand.ExecuteAsync(null);
|
||||||
|
fixture.Dialogs.Script = ScriptedDialogService.AnswerConfirmations(true);
|
||||||
|
await workspace.Tags.Tags[0].DeleteCommand.ExecuteAsync(null);
|
||||||
|
Assert.AreEqual(0, workspace.Board.Columns.Sum(column => column.CardCount));
|
||||||
|
|
||||||
|
// Re-tag through the repository, then a board write updates the usage count.
|
||||||
|
workspace.Board.ClearSearchCommand.Execute(null);
|
||||||
|
TagModel ui = fixture.Repository.AddTag("ui", new RgbColor(4, 5, 6), "");
|
||||||
|
fixture.Repository.UpdateCard(card.Id, "a", "", [ui.Id]);
|
||||||
|
Assert.AreEqual(0, workspace.Tags.Tags.Count);
|
||||||
|
await MoveAsync(workspace.Board, card.Id, second.Id);
|
||||||
|
Assert.AreEqual(1, workspace.Tags.Tags.Single().UsageCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public async Task ExportWritesTheWholeWorkspaceIgnoringTheSearch()
|
||||||
|
{
|
||||||
|
using var fixture = new Fixture();
|
||||||
|
ColumnModel column = fixture.Repository.AddColumn("A", "");
|
||||||
|
fixture.Repository.AddCard(column.Id, "alpha", "", []);
|
||||||
|
fixture.Repository.AddCard(column.Id, "beta", "", []);
|
||||||
|
WorkspaceViewModel workspace = fixture.CreateWorkspace();
|
||||||
|
workspace.Board.SearchText = "alpha";
|
||||||
|
await workspace.Board.SearchCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
string path = Path.Combine(fixture.Directory.FullPath, "out.md");
|
||||||
|
fixture.Dialogs.SaveFilePath = path;
|
||||||
|
await workspace.ExportCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
string suggested = fixture.Dialogs.SuggestedFileNames.Single();
|
||||||
|
StringAssert.StartsWith(suggested, workspace.WorkspaceName);
|
||||||
|
StringAssert.EndsWith(suggested, ".md");
|
||||||
|
string markdown = File.ReadAllText(path);
|
||||||
|
StringAssert.Contains(markdown, "alpha");
|
||||||
|
StringAssert.Contains(markdown, "beta");
|
||||||
|
byte[] bytes = File.ReadAllBytes(path);
|
||||||
|
Assert.AreNotEqual(0xEF, bytes[0], "The export must not start with a UTF-8 BOM.");
|
||||||
|
Assert.AreEqual(0, fixture.Dialogs.Shown.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public async Task CancelledExportWritesNothing()
|
||||||
|
{
|
||||||
|
using var fixture = new Fixture();
|
||||||
|
WorkspaceViewModel workspace = fixture.CreateWorkspace();
|
||||||
|
fixture.Dialogs.SaveFilePath = null;
|
||||||
|
|
||||||
|
await workspace.ExportCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
Assert.AreEqual(1, fixture.Dialogs.SuggestedFileNames.Count);
|
||||||
|
Assert.AreEqual(0, fixture.Dialogs.Shown.Count);
|
||||||
|
Assert.AreEqual(0, System.IO.Directory.GetFiles(fixture.Directory.FullPath, "*.md").Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public async Task ExportWriteFailureIsReportedInAMessageDialog()
|
||||||
|
{
|
||||||
|
using var fixture = new Fixture();
|
||||||
|
WorkspaceViewModel workspace = fixture.CreateWorkspace();
|
||||||
|
fixture.Dialogs.SaveFilePath = Path.Combine(fixture.Directory.FullPath, "missing-folder", "out.md");
|
||||||
|
|
||||||
|
await workspace.ExportCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
var message = (MessageDialogViewModel)fixture.Dialogs.Shown.Single();
|
||||||
|
Assert.AreEqual(Resources.Export_Failed_Title, message.Title);
|
||||||
|
Assert.IsTrue(message.HasDetails);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
@@ -340,4 +340,52 @@
|
|||||||
<data name="Error_Operation_Message" xml:space="preserve">
|
<data name="Error_Operation_Message" xml:space="preserve">
|
||||||
<value>The change could not be written to the workspace. The board shows the stored state.</value>
|
<value>The change could not be written to the workspace. The board shows the stored state.</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="TagEditor_TitleNew" xml:space="preserve">
|
||||||
|
<value>New tag</value>
|
||||||
|
</data>
|
||||||
|
<data name="TagEditor_TitleEdit" xml:space="preserve">
|
||||||
|
<value>Edit tag</value>
|
||||||
|
</data>
|
||||||
|
<data name="TagEditor_ColorLabel" xml:space="preserve">
|
||||||
|
<value>Color</value>
|
||||||
|
</data>
|
||||||
|
<data name="Tags_AddTag" xml:space="preserve">
|
||||||
|
<value>New tag</value>
|
||||||
|
</data>
|
||||||
|
<data name="Tags_Summary" xml:space="preserve">
|
||||||
|
<value>{0} tag(s)</value>
|
||||||
|
</data>
|
||||||
|
<data name="Tags_UsageCount" xml:space="preserve">
|
||||||
|
<value>{0} card(s)</value>
|
||||||
|
</data>
|
||||||
|
<data name="Tags_Empty" xml:space="preserve">
|
||||||
|
<value>No tags yet.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Confirm_DeleteTag" xml:space="preserve">
|
||||||
|
<value>Delete tag "{0}"? It will be removed from {1} card(s).</value>
|
||||||
|
</data>
|
||||||
|
<data name="Board_SearchWatermark" xml:space="preserve">
|
||||||
|
<value>Search, e.g. tag:bug OR title:login</value>
|
||||||
|
</data>
|
||||||
|
<data name="Board_Search" xml:space="preserve">
|
||||||
|
<value>Search</value>
|
||||||
|
</data>
|
||||||
|
<data name="Board_ClearSearch" xml:space="preserve">
|
||||||
|
<value>Clear</value>
|
||||||
|
</data>
|
||||||
|
<data name="Search_Error_Title" xml:space="preserve">
|
||||||
|
<value>Invalid search</value>
|
||||||
|
</data>
|
||||||
|
<data name="Search_Error_Message" xml:space="preserve">
|
||||||
|
<value>The query expression is invalid. Details: {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="Export_FileTypeMarkdown" xml:space="preserve">
|
||||||
|
<value>Markdown document</value>
|
||||||
|
</data>
|
||||||
|
<data name="Export_Failed_Title" xml:space="preserve">
|
||||||
|
<value>Export failed</value>
|
||||||
|
</data>
|
||||||
|
<data name="Export_Failed_Message" xml:space="preserve">
|
||||||
|
<value>The export file could not be written.</value>
|
||||||
|
</data>
|
||||||
</root>
|
</root>
|
||||||
@@ -340,4 +340,52 @@
|
|||||||
<data name="Error_Operation_Message" xml:space="preserve">
|
<data name="Error_Operation_Message" xml:space="preserve">
|
||||||
<value>更改未能写入工作区,看板显示的是已保存的状态。</value>
|
<value>更改未能写入工作区,看板显示的是已保存的状态。</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="TagEditor_TitleNew" xml:space="preserve">
|
||||||
|
<value>新建标签</value>
|
||||||
|
</data>
|
||||||
|
<data name="TagEditor_TitleEdit" xml:space="preserve">
|
||||||
|
<value>编辑标签</value>
|
||||||
|
</data>
|
||||||
|
<data name="TagEditor_ColorLabel" xml:space="preserve">
|
||||||
|
<value>颜色</value>
|
||||||
|
</data>
|
||||||
|
<data name="Tags_AddTag" xml:space="preserve">
|
||||||
|
<value>新建标签</value>
|
||||||
|
</data>
|
||||||
|
<data name="Tags_Summary" xml:space="preserve">
|
||||||
|
<value>共 {0} 个标签</value>
|
||||||
|
</data>
|
||||||
|
<data name="Tags_UsageCount" xml:space="preserve">
|
||||||
|
<value>{0} 张卡片</value>
|
||||||
|
</data>
|
||||||
|
<data name="Tags_Empty" xml:space="preserve">
|
||||||
|
<value>还没有标签。</value>
|
||||||
|
</data>
|
||||||
|
<data name="Confirm_DeleteTag" xml:space="preserve">
|
||||||
|
<value>删除标签“{0}”?将从 {1} 张卡片移除该标签。</value>
|
||||||
|
</data>
|
||||||
|
<data name="Board_SearchWatermark" xml:space="preserve">
|
||||||
|
<value>搜索,例如 tag:bug OR title:登录</value>
|
||||||
|
</data>
|
||||||
|
<data name="Board_Search" xml:space="preserve">
|
||||||
|
<value>搜索</value>
|
||||||
|
</data>
|
||||||
|
<data name="Board_ClearSearch" xml:space="preserve">
|
||||||
|
<value>清空</value>
|
||||||
|
</data>
|
||||||
|
<data name="Search_Error_Title" xml:space="preserve">
|
||||||
|
<value>搜索表达式错误</value>
|
||||||
|
</data>
|
||||||
|
<data name="Search_Error_Message" xml:space="preserve">
|
||||||
|
<value>查询表达式有误,详情:{0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="Export_FileTypeMarkdown" xml:space="preserve">
|
||||||
|
<value>Markdown 文档</value>
|
||||||
|
</data>
|
||||||
|
<data name="Export_Failed_Title" xml:space="preserve">
|
||||||
|
<value>导出失败</value>
|
||||||
|
</data>
|
||||||
|
<data name="Export_Failed_Message" xml:space="preserve">
|
||||||
|
<value>无法写入导出文件。</value>
|
||||||
|
</data>
|
||||||
</root>
|
</root>
|
||||||
@@ -11,8 +11,12 @@ using YKanBan.ViewModels.Dialogs;
|
|||||||
namespace YKanBan.ViewModels.Board;
|
namespace YKanBan.ViewModels.Board;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The board tab: the toolbar (new column, column width, card sort) and the
|
/// The board tab: the toolbar (search bar, new column, column width, card sort)
|
||||||
/// columns laid out left to right, each listing its cards top to bottom.
|
/// and the columns laid out left to right, each listing its cards top to bottom.
|
||||||
|
///
|
||||||
|
/// The search runs only on an explicit search (button or Enter), never while
|
||||||
|
/// typing. An invalid expression is reported in a message dialog and changes
|
||||||
|
/// nothing: the box keeps its text and the board keeps the previous result.
|
||||||
///
|
///
|
||||||
/// Every write is followed by <see cref="Refresh"/>, which re-queries with the
|
/// Every write is followed by <see cref="Refresh"/>, which re-queries with the
|
||||||
/// most recently successful search expression (<see cref="ActiveFilter"/>), not
|
/// most recently successful search expression (<see cref="ActiveFilter"/>), not
|
||||||
@@ -50,6 +54,12 @@ public sealed partial class BoardViewModel : ViewModelBase
|
|||||||
Refresh();
|
Refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raised after every board write (the refresh included), so the tags tab
|
||||||
|
/// can update its usage counts.
|
||||||
|
/// </summary>
|
||||||
|
public event EventHandler? DataWritten;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the columns in id order.
|
/// Gets the columns in id order.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -70,6 +80,18 @@ public sealed partial class BoardViewModel : ViewModelBase
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public CompiledSearch? ActiveFilter { get; set; }
|
public CompiledSearch? ActiveFilter { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the search box text; it only takes effect on <see cref="SearchCommand"/>.
|
||||||
|
/// </summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(HasSearchText))]
|
||||||
|
private string searchText = "";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets whether the search box holds text (shows the clear button).
|
||||||
|
/// </summary>
|
||||||
|
public bool HasSearchText => SearchText.Length > 0;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the selected card sort option; changing it is persisted and re-sorts the board.
|
/// Gets or sets the selected card sort option; changing it is persisted and re-sorts the board.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -99,6 +121,43 @@ public sealed partial class BoardViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses and runs the search box text; blank text shows every card. An
|
||||||
|
/// invalid expression only shows the error dialog.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A task completing after the refresh or the error dialog.</returns>
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task SearchAsync()
|
||||||
|
{
|
||||||
|
CompiledSearch? filter;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
SearchNode? query = SearchQueryParser.Parse(SearchText);
|
||||||
|
filter = query is null ? null : SearchSqlCompiler.Compile(query);
|
||||||
|
}
|
||||||
|
catch (SearchSyntaxException exception)
|
||||||
|
{
|
||||||
|
await _dialogs.ShowMessageAsync(
|
||||||
|
Resources.Search_Error_Title,
|
||||||
|
string.Format(Resources.Culture, Resources.Search_Error_Message, exception.Message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ActiveFilter = filter;
|
||||||
|
Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Empties the search box and shows every card again.
|
||||||
|
/// </summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private void ClearSearch()
|
||||||
|
{
|
||||||
|
SearchText = "";
|
||||||
|
ActiveFilter = null;
|
||||||
|
Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Opens the new-column dialog.
|
/// Opens the new-column dialog.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -109,7 +168,7 @@ public sealed partial class BoardViewModel : ViewModelBase
|
|||||||
var dialog = new ColumnEditorDialogViewModel(null, (title, description) => _repository.AddColumn(title, description));
|
var dialog = new ColumnEditorDialogViewModel(null, (title, description) => _repository.AddColumn(title, description));
|
||||||
if (await _dialogs.ShowAsync(dialog))
|
if (await _dialogs.ShowAsync(dialog))
|
||||||
{
|
{
|
||||||
Refresh();
|
RefreshAfterWrite();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,7 +196,7 @@ public sealed partial class BoardViewModel : ViewModelBase
|
|||||||
column, (title, description) => _repository.UpdateColumn(column.Id, title, description));
|
column, (title, description) => _repository.UpdateColumn(column.Id, title, description));
|
||||||
if (await _dialogs.ShowAsync(dialog))
|
if (await _dialogs.ShowAsync(dialog))
|
||||||
{
|
{
|
||||||
Refresh();
|
RefreshAfterWrite();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,7 +235,7 @@ public sealed partial class BoardViewModel : ViewModelBase
|
|||||||
await _dialogs.ShowAsync(editor);
|
await _dialogs.ShowAsync(editor);
|
||||||
|
|
||||||
// Refresh even after a cancel: the nested picker may have created tags.
|
// Refresh even after a cancel: the nested picker may have created tags.
|
||||||
Refresh();
|
RefreshAfterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -196,7 +255,7 @@ public sealed partial class BoardViewModel : ViewModelBase
|
|||||||
var editor = CardEditorDialogViewModel.ForExistingCard(
|
var editor = CardEditorDialogViewModel.ForExistingCard(
|
||||||
_repository, _dialogs, card, _config.Confirmations.DiscardEdit);
|
_repository, _dialogs, card, _config.Confirmations.DiscardEdit);
|
||||||
await _dialogs.ShowAsync(editor);
|
await _dialogs.ShowAsync(editor);
|
||||||
Refresh();
|
RefreshAfterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -255,7 +314,16 @@ public sealed partial class BoardViewModel : ViewModelBase
|
|||||||
await _dialogs.ShowMessageAsync(
|
await _dialogs.ShowMessageAsync(
|
||||||
Resources.Error_Operation_Title, Resources.Error_Operation_Message, exception.Message);
|
Resources.Error_Operation_Title, Resources.Error_Operation_Message, exception.Message);
|
||||||
}
|
}
|
||||||
|
RefreshAfterWrite();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Refreshes after a write and announces it.
|
||||||
|
/// </summary>
|
||||||
|
private void RefreshAfterWrite()
|
||||||
|
{
|
||||||
Refresh();
|
Refresh();
|
||||||
|
DataWritten?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ public interface IDialogService
|
|||||||
/// <param name="dialog">The dialog view model; its window is resolved by naming convention.</param>
|
/// <param name="dialog">The dialog view model; its window is resolved by naming convention.</param>
|
||||||
/// <returns>The dialog result: <see langword="true"/> when it was accepted.</returns>
|
/// <returns>The dialog result: <see langword="true"/> when it was accepted.</returns>
|
||||||
Task<bool> ShowAsync(DialogViewModelBase dialog);
|
Task<bool> ShowAsync(DialogViewModelBase dialog);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shows the system save-file dialog for a Markdown file.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="suggestedFileName">The proposed file name.</param>
|
||||||
|
/// <returns>The chosen local path, or <see langword="null"/> when the user cancelled.</returns>
|
||||||
|
Task<string?> PickSaveFilePathAsync(string suggestedFileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using YKanBan.Models;
|
||||||
|
|
||||||
|
namespace YKanBan.ViewModels.Dialogs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The default colors proposed for new tags, shared by the tag picker and the
|
||||||
|
/// tag editor.
|
||||||
|
/// </summary>
|
||||||
|
public static class TagColors
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the palette, offered as swatches and cycled for new-tag defaults.
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyList<RgbColor> Palette { get; } =
|
||||||
|
[
|
||||||
|
new(0xd7, 0x3a, 0x4a), new(0x00, 0x75, 0xca), new(0x0e, 0x8a, 0x16), new(0xfb, 0xca, 0x04),
|
||||||
|
new(0x7c, 0x3a, 0xed), new(0xe9, 0x9b, 0x3c), new(0x00, 0x8b, 0x8b), new(0xcf, 0xd3, 0xd7),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the default color of a new tag, cycled by the number of existing tags.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="existingCount">The number of tags already in the workspace.</param>
|
||||||
|
/// <returns>The proposed color.</returns>
|
||||||
|
public static RgbColor DefaultFor(int existingCount) => Palette[existingCount % Palette.Count];
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
using Avalonia.Media;
|
||||||
|
using Avalonia.Media.Immutable;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using YKanBan.Models;
|
||||||
|
|
||||||
|
namespace YKanBan.ViewModels.Dialogs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The new-tag / edit-tag dialog of the tags tab: name (required, not trimmed),
|
||||||
|
/// #RRGGBB color with palette swatches and a live preview, and an optional
|
||||||
|
/// description. Confirming writes through the supplied save callback; an invalid
|
||||||
|
/// color or a schema-constraint violation (empty or duplicate name) is shown
|
||||||
|
/// inside the dialog, which stays open with the user's input intact.
|
||||||
|
/// </summary>
|
||||||
|
public sealed partial class TagEditorDialogViewModel : DialogViewModelBase
|
||||||
|
{
|
||||||
|
private readonly Action<string, RgbColor, string> _save;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes the dialog.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="existing">The tag being edited, or <see langword="null"/> for a new tag.</param>
|
||||||
|
/// <param name="defaultColor">The color proposed for a new tag.</param>
|
||||||
|
/// <param name="save">Writes the name, color and description; throws the repository's typed exceptions.</param>
|
||||||
|
public TagEditorDialogViewModel(TagModel? existing, RgbColor defaultColor, Action<string, RgbColor, string> save)
|
||||||
|
{
|
||||||
|
_save = save;
|
||||||
|
Title = existing is null ? Resources.TagEditor_TitleNew : Resources.TagEditor_TitleEdit;
|
||||||
|
name = existing?.Name ?? "";
|
||||||
|
color = (existing?.Color ?? defaultColor).ToString();
|
||||||
|
description = existing?.Description ?? "";
|
||||||
|
Swatches = TagColors.Palette.Select(swatch => new ColorSwatch(swatch.ToString(), ToBrush(swatch))).ToList();
|
||||||
|
UpdateColorPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the localized window title.
|
||||||
|
/// </summary>
|
||||||
|
public string Title { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the palette swatches; clicking one sets <see cref="Color"/>.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<ColorSwatch> Swatches { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the tag name being edited (not trimmed).
|
||||||
|
/// </summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private string name;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the #RRGGBB color text being edited.
|
||||||
|
/// </summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private string color;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the tag description being edited.
|
||||||
|
/// </summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private string description;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the preview of <see cref="Color"/>, transparent while it is not valid.
|
||||||
|
/// </summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private IBrush colorPreview = Brushes.Transparent;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the localized error of the last confirmation, or <see langword="null"/>.
|
||||||
|
/// </summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(HasError))]
|
||||||
|
private string? errorMessage;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets whether an error is shown.
|
||||||
|
/// </summary>
|
||||||
|
public bool HasError => ErrorMessage is not null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Takes a swatch color.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="hex">The swatch's #RRGGBB text.</param>
|
||||||
|
[RelayCommand]
|
||||||
|
private void PickSwatch(string hex) => Color = hex;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes the tag; closes on success, otherwise shows the error.
|
||||||
|
/// </summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private void Confirm()
|
||||||
|
{
|
||||||
|
if (!RgbColor.TryParse(Color, out RgbColor parsed))
|
||||||
|
{
|
||||||
|
ErrorMessage = Resources.Error_Tag_InvalidColor;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_save(Name, parsed, Description);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
ErrorMessage = WriteErrorMessages.Describe(exception);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Close(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Closes without writing.
|
||||||
|
/// </summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private Task CancelAsync() => RequestCancelAsync();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Refreshes the color preview.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="value">The new color text.</param>
|
||||||
|
partial void OnColorChanged(string value) => UpdateColorPreview();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Recomputes <see cref="ColorPreview"/> from <see cref="Color"/>.
|
||||||
|
/// </summary>
|
||||||
|
private void UpdateColorPreview() =>
|
||||||
|
ColorPreview = RgbColor.TryParse(Color, out RgbColor parsed) ? ToBrush(parsed) : Brushes.Transparent;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates the thread-agnostic brush of a tag color.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="value">The color.</param>
|
||||||
|
/// <returns>The brush.</returns>
|
||||||
|
private static IBrush ToBrush(RgbColor value) =>
|
||||||
|
new ImmutableSolidColorBrush(Avalonia.Media.Color.FromRgb(value.R, value.G, value.B));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A palette swatch of the tag editor.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Hex">The #RRGGBB text.</param>
|
||||||
|
/// <param name="Brush">The fill.</param>
|
||||||
|
public sealed record ColorSwatch(string Hex, IBrush Brush);
|
||||||
@@ -21,15 +21,6 @@ namespace YKanBan.ViewModels.Dialogs;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed partial class TagPickerDialogViewModel : DialogViewModelBase
|
public sealed partial class TagPickerDialogViewModel : DialogViewModelBase
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// Default colors proposed for new tags, cycled by the number of existing tags.
|
|
||||||
/// </summary>
|
|
||||||
private static readonly RgbColor[] Palette =
|
|
||||||
[
|
|
||||||
new(0xd7, 0x3a, 0x4a), new(0x00, 0x75, 0xca), new(0x0e, 0x8a, 0x16), new(0xfb, 0xca, 0x04),
|
|
||||||
new(0x7c, 0x3a, 0xed), new(0xe9, 0x9b, 0x3c), new(0x00, 0x8b, 0x8b), new(0xcf, 0xd3, 0xd7),
|
|
||||||
];
|
|
||||||
|
|
||||||
private readonly WorkspaceRepository _repository;
|
private readonly WorkspaceRepository _repository;
|
||||||
private readonly IReadOnlyList<TagBadgeViewModel> _available;
|
private readonly IReadOnlyList<TagBadgeViewModel> _available;
|
||||||
|
|
||||||
@@ -48,7 +39,7 @@ public sealed partial class TagPickerDialogViewModel : DialogViewModelBase
|
|||||||
.ToList();
|
.ToList();
|
||||||
FilteredTags = new ObservableCollection<TagBadgeViewModel>(_available);
|
FilteredTags = new ObservableCollection<TagBadgeViewModel>(_available);
|
||||||
|
|
||||||
newColor = Palette[all.Count % Palette.Length].ToString();
|
newColor = TagColors.DefaultFor(all.Count).ToString();
|
||||||
UpdateColorPreview();
|
UpdateColorPreview();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using YKanBan.Models;
|
||||||
|
using YKanBan.ViewModels.Board;
|
||||||
|
|
||||||
|
namespace YKanBan.ViewModels.Tags;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One row of the tags tab: the colored badge, the description and the usage
|
||||||
|
/// count, with edit and delete actions.
|
||||||
|
/// </summary>
|
||||||
|
public sealed partial class TagRowViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
private readonly TagsViewModel _owner;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes the row.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="owner">The tags tab performing the actions.</param>
|
||||||
|
/// <param name="tag">The tag shown.</param>
|
||||||
|
/// <param name="usageCount">The number of cards carrying the tag.</param>
|
||||||
|
public TagRowViewModel(TagsViewModel owner, TagModel tag, long usageCount)
|
||||||
|
{
|
||||||
|
_owner = owner;
|
||||||
|
Tag = tag;
|
||||||
|
Badge = new TagBadgeViewModel(tag);
|
||||||
|
UsageCount = usageCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the tag shown.
|
||||||
|
/// </summary>
|
||||||
|
public TagModel Tag { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the colored badge (color + name).
|
||||||
|
/// </summary>
|
||||||
|
public TagBadgeViewModel Badge { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the tag description.
|
||||||
|
/// </summary>
|
||||||
|
public string Description => Tag.Description;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets whether the tag has a description.
|
||||||
|
/// </summary>
|
||||||
|
public bool HasDescription => Tag.Description.Length > 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the #RRGGBB color text.
|
||||||
|
/// </summary>
|
||||||
|
public string ColorText => Tag.Color.ToString();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the number of cards carrying the tag.
|
||||||
|
/// </summary>
|
||||||
|
public long UsageCount { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the localized usage count, for example "3 cards".
|
||||||
|
/// </summary>
|
||||||
|
public string UsageText => string.Format(Resources.Culture, Resources.Tags_UsageCount, UsageCount);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens the edit dialog.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A task completing when the dialog closes.</returns>
|
||||||
|
[RelayCommand]
|
||||||
|
private Task EditAsync() => _owner.EditTagAsync(Tag);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes the tag.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A task completing when the tag is deleted or the deletion was declined.</returns>
|
||||||
|
[RelayCommand]
|
||||||
|
private Task DeleteAsync() => _owner.DeleteTagAsync(Tag, UsageCount);
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using YKanBan.Models;
|
||||||
|
using YKanBan.Storage.AppData;
|
||||||
|
using YKanBan.Storage.Workspace;
|
||||||
|
using YKanBan.ViewModels.Dialogs;
|
||||||
|
|
||||||
|
namespace YKanBan.ViewModels.Tags;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The tags tab, a tag manager in the style of the GitHub issue labels page:
|
||||||
|
/// every tag with its color, name, description and usage count, plus creating,
|
||||||
|
/// editing (rename, recolor, describe) and deleting tags. Unreferenced tags are
|
||||||
|
/// never cleaned up automatically, so this is where they are removed.
|
||||||
|
/// </summary>
|
||||||
|
public sealed partial class TagsViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
private readonly WorkspaceRepository _repository;
|
||||||
|
private readonly AppConfig _config;
|
||||||
|
private readonly IDialogService _dialogs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes the tab and loads it.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="repository">The workspace repository.</param>
|
||||||
|
/// <param name="config">The live application configuration (delete confirmation).</param>
|
||||||
|
/// <param name="dialogs">The modal dialog service.</param>
|
||||||
|
public TagsViewModel(WorkspaceRepository repository, AppConfig config, IDialogService dialogs)
|
||||||
|
{
|
||||||
|
_repository = repository;
|
||||||
|
_config = config;
|
||||||
|
_dialogs = dialogs;
|
||||||
|
Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raised after every tag write, so the board can show the new names and colors.
|
||||||
|
/// </summary>
|
||||||
|
public event EventHandler? TagsChanged;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the tags in id order.
|
||||||
|
/// </summary>
|
||||||
|
public ObservableCollection<TagRowViewModel> Tags { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the localized total, for example "3 tags".
|
||||||
|
/// </summary>
|
||||||
|
public string Summary => string.Format(Resources.Culture, Resources.Tags_Summary, Tags.Count);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets whether the workspace has no tags yet.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsEmpty => Tags.Count == 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reloads the tags with their usage counts.
|
||||||
|
/// </summary>
|
||||||
|
public void Refresh()
|
||||||
|
{
|
||||||
|
Tags.Clear();
|
||||||
|
foreach ((TagModel tag, long usage) in _repository.GetTagsWithUsage())
|
||||||
|
{
|
||||||
|
Tags.Add(new TagRowViewModel(this, tag, usage));
|
||||||
|
}
|
||||||
|
OnPropertyChanged(nameof(Summary));
|
||||||
|
OnPropertyChanged(nameof(IsEmpty));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens the new-tag dialog.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A task completing when the dialog closes.</returns>
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task AddTagAsync()
|
||||||
|
{
|
||||||
|
var dialog = new TagEditorDialogViewModel(
|
||||||
|
null,
|
||||||
|
TagColors.DefaultFor(Tags.Count),
|
||||||
|
(name, color, description) => _repository.AddTag(name, color, description));
|
||||||
|
if (await _dialogs.ShowAsync(dialog))
|
||||||
|
{
|
||||||
|
AfterWrite();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens the edit-tag dialog.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="tag">The tag to edit.</param>
|
||||||
|
/// <returns>A task completing when the dialog closes.</returns>
|
||||||
|
internal async Task EditTagAsync(TagModel tag)
|
||||||
|
{
|
||||||
|
var dialog = new TagEditorDialogViewModel(
|
||||||
|
tag,
|
||||||
|
tag.Color,
|
||||||
|
(name, color, description) => _repository.UpdateTag(tag.Id, name, color, description));
|
||||||
|
if (await _dialogs.ShowAsync(dialog))
|
||||||
|
{
|
||||||
|
AfterWrite();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes a tag (removing it from its cards), asking first when that
|
||||||
|
/// confirmation is enabled.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="tag">The tag to delete.</param>
|
||||||
|
/// <param name="usageCount">The number of cards carrying it.</param>
|
||||||
|
/// <returns>A task completing when the tag is deleted or the deletion was declined.</returns>
|
||||||
|
internal async Task DeleteTagAsync(TagModel tag, long usageCount)
|
||||||
|
{
|
||||||
|
if (_config.Confirmations.DeleteTag)
|
||||||
|
{
|
||||||
|
string message = string.Format(Resources.Culture, Resources.Confirm_DeleteTag, tag.Name, usageCount);
|
||||||
|
if (!await _dialogs.ConfirmAsync(message, Resources.Common_Delete))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_repository.DeleteTag(tag.Id);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
Debug.WriteLine(exception);
|
||||||
|
await _dialogs.ShowMessageAsync(
|
||||||
|
Resources.Error_Operation_Title, Resources.Error_Operation_Message, exception.Message);
|
||||||
|
}
|
||||||
|
AfterWrite();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reloads the list and tells the board that tags changed.
|
||||||
|
/// </summary>
|
||||||
|
private void AfterWrite()
|
||||||
|
{
|
||||||
|
Refresh();
|
||||||
|
TagsChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +1,26 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Text;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using YKanBan.Export;
|
||||||
using YKanBan.Storage.AppData;
|
using YKanBan.Storage.AppData;
|
||||||
using YKanBan.Storage.Workspace;
|
using YKanBan.Storage.Workspace;
|
||||||
using YKanBan.ViewModels.Board;
|
using YKanBan.ViewModels.Board;
|
||||||
using YKanBan.ViewModels.Dialogs;
|
using YKanBan.ViewModels.Dialogs;
|
||||||
|
using YKanBan.ViewModels.Tags;
|
||||||
|
|
||||||
namespace YKanBan.ViewModels;
|
namespace YKanBan.ViewModels;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The normal workspace view: the top display area (name, path and the export,
|
/// The normal workspace view: the top display area (name, path and the export,
|
||||||
/// settings and about buttons) plus the tags/board tabs. The tab contents are
|
/// settings and about buttons) plus the board and tags tabs. A write on either
|
||||||
/// filled in by the board and tag features.
|
/// tab refreshes the other: tag edits change the board's badges and filter
|
||||||
|
/// matches, card edits change the tags' usage counts.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed partial class WorkspaceViewModel : ViewModelBase
|
public sealed partial class WorkspaceViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
private readonly Interaction<Unit> _showSettings;
|
private readonly Interaction<Unit> _showSettings;
|
||||||
private readonly Interaction<Unit> _showAbout;
|
private readonly Interaction<Unit> _showAbout;
|
||||||
|
private readonly IDialogService _dialogs;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes the view for an open workspace.
|
/// Initializes the view for an open workspace.
|
||||||
@@ -23,7 +29,7 @@ public sealed partial class WorkspaceViewModel : ViewModelBase
|
|||||||
/// <param name="showSettings">The interaction that opens the settings dialog.</param>
|
/// <param name="showSettings">The interaction that opens the settings dialog.</param>
|
||||||
/// <param name="showAbout">The interaction that opens the about dialog.</param>
|
/// <param name="showAbout">The interaction that opens the about dialog.</param>
|
||||||
/// <param name="config">The live application configuration.</param>
|
/// <param name="config">The live application configuration.</param>
|
||||||
/// <param name="dialogs">The modal dialog service of the board.</param>
|
/// <param name="dialogs">The modal dialog service.</param>
|
||||||
public WorkspaceViewModel(
|
public WorkspaceViewModel(
|
||||||
WorkspaceSession session,
|
WorkspaceSession session,
|
||||||
Interaction<Unit> showSettings,
|
Interaction<Unit> showSettings,
|
||||||
@@ -36,7 +42,11 @@ public sealed partial class WorkspaceViewModel : ViewModelBase
|
|||||||
WorkspacePath = session.FolderPath;
|
WorkspacePath = session.FolderPath;
|
||||||
_showSettings = showSettings;
|
_showSettings = showSettings;
|
||||||
_showAbout = showAbout;
|
_showAbout = showAbout;
|
||||||
|
_dialogs = dialogs;
|
||||||
Board = new BoardViewModel(session.Repository, config, dialogs);
|
Board = new BoardViewModel(session.Repository, config, dialogs);
|
||||||
|
Tags = new TagsViewModel(session.Repository, config, dialogs);
|
||||||
|
Board.DataWritten += (_, _) => Tags.Refresh();
|
||||||
|
Tags.TagsChanged += (_, _) => Board.Refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -59,6 +69,11 @@ public sealed partial class WorkspaceViewModel : ViewModelBase
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public BoardViewModel Board { get; }
|
public BoardViewModel Board { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the tags tab.
|
||||||
|
/// </summary>
|
||||||
|
public TagsViewModel Tags { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Opens the settings dialog through the registered interaction.
|
/// Opens the settings dialog through the registered interaction.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -72,11 +87,32 @@ public sealed partial class WorkspaceViewModel : ViewModelBase
|
|||||||
private async Task OpenAboutAsync() => await _showAbout.Handle(Unit.Value);
|
private async Task OpenAboutAsync() => await _showAbout.Handle(Unit.Value);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Raised by the export button; the export flow is wired up with the export
|
/// Exports the whole workspace to a Markdown file chosen in the system save
|
||||||
/// feature.
|
/// dialog. The export ignores the board's search filter and card sort; the
|
||||||
|
/// default file name and all template text follow the UI language.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task completing when the file is written or the export was cancelled.</returns>
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private static void Export()
|
private async Task ExportAsync()
|
||||||
{
|
{
|
||||||
|
DateTimeOffset exportedAt = DateTimeOffset.Now;
|
||||||
|
string? path = await _dialogs.PickSaveFilePathAsync(MarkdownExporter.BuildFileName(WorkspaceName, exportedAt));
|
||||||
|
if (path is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
WorkspaceRepository repository = Session.Repository;
|
||||||
|
string markdown = MarkdownExporter.Export(
|
||||||
|
WorkspaceName, repository.GetColumns(), repository.GetCards(), repository.GetTagsWithUsage(), exportedAt);
|
||||||
|
await File.WriteAllTextAsync(path, markdown, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
Debug.WriteLine(exception);
|
||||||
|
await _dialogs.ShowMessageAsync(Resources.Export_Failed_Title, Resources.Export_Failed_Message, exception.Message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -13,29 +13,54 @@
|
|||||||
|
|
||||||
<Grid RowDefinitions="Auto,*" RowSpacing="8">
|
<Grid RowDefinitions="Auto,*" RowSpacing="8">
|
||||||
|
|
||||||
<!-- Toolbar. The search bar joins on the left with the search feature. -->
|
<!-- Toolbar: search bar on the left; new column, column width and card sort on the right. -->
|
||||||
<StackPanel Grid.Row="0" Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8">
|
<Grid Grid.Row="0" ColumnDefinitions="*,Auto" ColumnSpacing="16">
|
||||||
<Button Command="{Binding AddColumnCommand}">
|
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="8">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
<TextBox Text="{Binding SearchText}" Width="420"
|
||||||
<icons:PackIconLucide Kind="Plus" Width="16" Height="16"/>
|
Watermark="{x:Static lang:Resources.Board_SearchWatermark}">
|
||||||
<TextBlock Text="{x:Static lang:Resources.Board_AddColumn}"/>
|
<TextBox.KeyBindings>
|
||||||
</StackPanel>
|
<KeyBinding Gesture="Enter" Command="{Binding SearchCommand}"/>
|
||||||
</Button>
|
</TextBox.KeyBindings>
|
||||||
<Button Command="{Binding AdjustColumnWidthCommand}">
|
<TextBox.InnerLeftContent>
|
||||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
<icons:PackIconLucide Kind="Search" Width="14" Height="14" Margin="8,0,0,0" Opacity="0.6"
|
||||||
<icons:PackIconLucide Kind="MoveHorizontal" Width="16" Height="16"/>
|
VerticalAlignment="Center"/>
|
||||||
<TextBlock Text="{x:Static lang:Resources.Board_ColumnWidth}"/>
|
</TextBox.InnerLeftContent>
|
||||||
</StackPanel>
|
<TextBox.InnerRightContent>
|
||||||
</Button>
|
<Button Command="{Binding ClearSearchCommand}" IsVisible="{Binding HasSearchText}"
|
||||||
<ComboBox ItemsSource="{Binding SortOptions}" SelectedItem="{Binding SelectedSort}" MinWidth="160"
|
Theme="{StaticResource BorderlessButton}" Padding="6,0"
|
||||||
ToolTip.Tip="{x:Static lang:Resources.Board_Sort}">
|
MinHeight="0" Height="24" VerticalAlignment="Center"
|
||||||
<ComboBox.ItemTemplate>
|
ToolTip.Tip="{x:Static lang:Resources.Board_ClearSearch}">
|
||||||
<DataTemplate x:DataType="vmBase:OptionItem">
|
<icons:PackIconLucide Kind="X" Width="14" Height="14"/>
|
||||||
<TextBlock Text="{Binding Label}"/>
|
</Button>
|
||||||
</DataTemplate>
|
</TextBox.InnerRightContent>
|
||||||
</ComboBox.ItemTemplate>
|
</TextBox>
|
||||||
</ComboBox>
|
<Button Command="{Binding SearchCommand}" Classes="Primary">
|
||||||
</StackPanel>
|
<TextBlock Text="{x:Static lang:Resources.Board_Search}"/>
|
||||||
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
|
||||||
|
<Button Command="{Binding AddColumnCommand}">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
|
<icons:PackIconLucide Kind="Plus" Width="16" Height="16"/>
|
||||||
|
<TextBlock Text="{x:Static lang:Resources.Board_AddColumn}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
<Button Command="{Binding AdjustColumnWidthCommand}">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
|
<icons:PackIconLucide Kind="MoveHorizontal" Width="16" Height="16"/>
|
||||||
|
<TextBlock Text="{x:Static lang:Resources.Board_ColumnWidth}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
<ComboBox ItemsSource="{Binding SortOptions}" SelectedItem="{Binding SelectedSort}" MinWidth="160"
|
||||||
|
ToolTip.Tip="{x:Static lang:Resources.Board_Sort}">
|
||||||
|
<ComboBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vmBase:OptionItem">
|
||||||
|
<TextBlock Text="{Binding Label}"/>
|
||||||
|
</DataTemplate>
|
||||||
|
</ComboBox.ItemTemplate>
|
||||||
|
</ComboBox>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
<!-- Columns left to right; each column scrolls its own virtualized card list. -->
|
<!-- Columns left to right; each column scrolls its own virtualized card list. -->
|
||||||
<ScrollViewer Grid.Row="1" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
|
<ScrollViewer Grid.Row="1" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Platform.Storage;
|
||||||
using YKanBan.ViewModels.Dialogs;
|
using YKanBan.ViewModels.Dialogs;
|
||||||
using YKanBan.Views.Dialogs;
|
using YKanBan.Views.Dialogs;
|
||||||
|
|
||||||
@@ -50,6 +51,27 @@ public sealed class DialogService : IDialogService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<string?> PickSaveFilePathAsync(string suggestedFileName)
|
||||||
|
{
|
||||||
|
if (!_owners.TryPeek(out Window? owner))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("The dialog service has no main window attached.");
|
||||||
|
}
|
||||||
|
|
||||||
|
IStorageFile? file = await owner.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
|
||||||
|
{
|
||||||
|
SuggestedFileName = suggestedFileName,
|
||||||
|
DefaultExtension = "md",
|
||||||
|
ShowOverwritePrompt = true,
|
||||||
|
FileTypeChoices =
|
||||||
|
[
|
||||||
|
new FilePickerFileType(YKanBan.Resources.Export_FileTypeMarkdown) { Patterns = ["*.md"] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return file?.TryGetLocalPath();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates the window class matching a dialog view model by naming convention.
|
/// Creates the window class matching a dialog view model by naming convention.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
x:Class="YKanBan.Views.Dialogs.ColumnEditorDialog"
|
x:Class="YKanBan.Views.Dialogs.ColumnEditorDialog"
|
||||||
x:DataType="vm:ColumnEditorDialogViewModel"
|
x:DataType="vm:ColumnEditorDialogViewModel"
|
||||||
Title="{Binding Title}"
|
Title="{Binding Title}"
|
||||||
Width="440"
|
SizeToContent="Height" Width="440"
|
||||||
WindowStartupLocation="CenterOwner">
|
WindowStartupLocation="CenterOwner">
|
||||||
|
|
||||||
<StackPanel Margin="20" Spacing="14">
|
<StackPanel Margin="20" Spacing="14">
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<dialogs:DialogWindow xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:YKanBan.ViewModels.Dialogs"
|
||||||
|
xmlns:dialogs="using:YKanBan.Views.Dialogs"
|
||||||
|
xmlns:lang="using:YKanBan"
|
||||||
|
x:Class="YKanBan.Views.Dialogs.TagEditorDialog"
|
||||||
|
x:DataType="vm:TagEditorDialogViewModel"
|
||||||
|
x:Name="Root"
|
||||||
|
Title="{Binding Title}"
|
||||||
|
SizeToContent="Height" Width="440"
|
||||||
|
WindowStartupLocation="CenterOwner">
|
||||||
|
|
||||||
|
<StackPanel Margin="20" Spacing="14">
|
||||||
|
<StackPanel Spacing="4">
|
||||||
|
<TextBlock Text="{x:Static lang:Resources.Field_Name}"/>
|
||||||
|
<TextBox Text="{Binding Name}"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
<TextBlock Text="{x:Static lang:Resources.TagEditor_ColorLabel}"/>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<TextBox Text="{Binding Color}" Width="120" Watermark="{x:Static lang:Resources.Field_Color}"/>
|
||||||
|
<Border Width="28" Height="28" CornerRadius="4" Background="{Binding ColorPreview}"
|
||||||
|
BorderBrush="{DynamicResource PanelBorderBrush}" BorderThickness="1"/>
|
||||||
|
</StackPanel>
|
||||||
|
<ItemsControl ItemsSource="{Binding Swatches}">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="6"/>
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:ColorSwatch">
|
||||||
|
<Button Command="{Binding #Root.((vm:TagEditorDialogViewModel)DataContext).PickSwatchCommand}"
|
||||||
|
CommandParameter="{Binding Hex}"
|
||||||
|
ToolTip.Tip="{Binding Hex}"
|
||||||
|
Width="24" Height="24" Padding="0" CornerRadius="4"
|
||||||
|
Background="{Binding Brush}"
|
||||||
|
BorderBrush="{DynamicResource PanelBorderBrush}" BorderThickness="1"/>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Spacing="4">
|
||||||
|
<TextBlock Text="{x:Static lang:Resources.Field_Description}"/>
|
||||||
|
<TextBox Text="{Binding Description}" AcceptsReturn="True" TextWrapping="Wrap"
|
||||||
|
MinHeight="72" MaxHeight="200"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<TextBlock Text="{Binding ErrorMessage}" IsVisible="{Binding HasError}"
|
||||||
|
Foreground="{DynamicResource ErrorTextBrush}" TextWrapping="Wrap"/>
|
||||||
|
|
||||||
|
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8">
|
||||||
|
<Button Content="{x:Static lang:Resources.Common_Ok}" Command="{Binding ConfirmCommand}"
|
||||||
|
Classes="Primary" MinWidth="90" IsDefault="True"/>
|
||||||
|
<Button Content="{x:Static lang:Resources.Common_Cancel}" Command="{Binding CancelCommand}"
|
||||||
|
MinWidth="90" IsCancel="True"/>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</dialogs:DialogWindow>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace YKanBan.Views.Dialogs;
|
||||||
|
|
||||||
|
public partial class TagEditorDialog : DialogWindow
|
||||||
|
{
|
||||||
|
public TagEditorDialog()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:YKanBan.ViewModels.Tags"
|
||||||
|
xmlns:lang="using:YKanBan"
|
||||||
|
xmlns:icons="using:IconPacks.Avalonia.Lucide"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
mc:Ignorable="d" d:DesignWidth="1000" d:DesignHeight="600"
|
||||||
|
x:Class="YKanBan.Views.Tags.TagsView"
|
||||||
|
x:DataType="vm:TagsViewModel">
|
||||||
|
|
||||||
|
<Grid RowDefinitions="Auto,*" RowSpacing="8">
|
||||||
|
|
||||||
|
<!-- Toolbar: tag total on the left, new tag on the right. -->
|
||||||
|
<Grid Grid.Row="0" ColumnDefinitions="*,Auto">
|
||||||
|
<TextBlock Grid.Column="0" Text="{Binding Summary}" VerticalAlignment="Center" Opacity="0.7"/>
|
||||||
|
<Button Grid.Column="1" Command="{Binding AddTagCommand}">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
|
<icons:PackIconLucide Kind="Plus" Width="16" Height="16"/>
|
||||||
|
<TextBlock Text="{x:Static lang:Resources.Tags_AddTag}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Tag list: badge, description, usage count, edit and delete. -->
|
||||||
|
<Border Grid.Row="1" BorderBrush="{DynamicResource PanelBorderBrush}" BorderThickness="1" CornerRadius="6">
|
||||||
|
<Panel>
|
||||||
|
<ItemsControl ItemsSource="{Binding Tags}">
|
||||||
|
<ItemsControl.Template>
|
||||||
|
<ControlTemplate>
|
||||||
|
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
|
||||||
|
<ItemsPresenter Name="PART_ItemsPresenter" ItemsPanel="{TemplateBinding ItemsPanel}"/>
|
||||||
|
</ScrollViewer>
|
||||||
|
</ControlTemplate>
|
||||||
|
</ItemsControl.Template>
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<VirtualizingStackPanel/>
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:TagRowViewModel">
|
||||||
|
<Border BorderBrush="{DynamicResource PanelBorderBrush}" BorderThickness="0,0,0,1"
|
||||||
|
Padding="16,10">
|
||||||
|
<Grid ColumnDefinitions="240,*,Auto,Auto" ColumnSpacing="16">
|
||||||
|
<Border Grid.Column="0" HorizontalAlignment="Left" VerticalAlignment="Center"
|
||||||
|
Background="{Binding Badge.Background}" CornerRadius="10" Padding="10,2"
|
||||||
|
ToolTip.Tip="{Binding ColorText}">
|
||||||
|
<TextBlock Text="{Binding Badge.Name}" Foreground="{Binding Badge.Foreground}"
|
||||||
|
FontSize="12" TextTrimming="CharacterEllipsis"/>
|
||||||
|
</Border>
|
||||||
|
<TextBlock Grid.Column="1" Text="{Binding Description}" VerticalAlignment="Center"
|
||||||
|
Opacity="0.75" TextTrimming="CharacterEllipsis"/>
|
||||||
|
<TextBlock Grid.Column="2" Text="{Binding UsageText}" VerticalAlignment="Center"
|
||||||
|
Opacity="0.7"/>
|
||||||
|
<StackPanel Grid.Column="3" Orientation="Horizontal" Spacing="4">
|
||||||
|
<Button Command="{Binding EditCommand}" Theme="{StaticResource BorderlessButton}"
|
||||||
|
Padding="6" ToolTip.Tip="{x:Static lang:Resources.Menu_Edit}">
|
||||||
|
<icons:PackIconLucide Kind="Pencil" Width="14" Height="14"/>
|
||||||
|
</Button>
|
||||||
|
<Button Command="{Binding DeleteCommand}" Theme="{StaticResource BorderlessButton}"
|
||||||
|
Padding="6" ToolTip.Tip="{x:Static lang:Resources.Menu_Delete}">
|
||||||
|
<icons:PackIconLucide Kind="Trash2" Width="14" Height="14"/>
|
||||||
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
<TextBlock Text="{x:Static lang:Resources.Tags_Empty}" IsVisible="{Binding IsEmpty}"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center" Opacity="0.6"/>
|
||||||
|
</Panel>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace YKanBan.Views.Tags;
|
||||||
|
|
||||||
|
public partial class TagsView : UserControl
|
||||||
|
{
|
||||||
|
public TagsView()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,12 +48,14 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- Board and tags tabs; the tags tab is filled in by the tag feature. -->
|
<!-- Board and tags tabs. -->
|
||||||
<TabControl Grid.Row="1" Margin="8">
|
<TabControl Grid.Row="1" Margin="8">
|
||||||
<TabItem Header="{x:Static lang:Resources.Tab_Board}">
|
<TabItem Header="{x:Static lang:Resources.Tab_Board}">
|
||||||
<ContentControl Content="{Binding Board}" Margin="0,8,0,0"/>
|
<ContentControl Content="{Binding Board}" Margin="0,8,0,0"/>
|
||||||
</TabItem>
|
</TabItem>
|
||||||
<TabItem Header="{x:Static lang:Resources.Tab_Tags}"/>
|
<TabItem Header="{x:Static lang:Resources.Tab_Tags}">
|
||||||
|
<ContentControl Content="{Binding Tags}" Margin="0,8,0,0"/>
|
||||||
|
</TabItem>
|
||||||
</TabControl>
|
</TabControl>
|
||||||
|
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
+1
-1
@@ -374,7 +374,7 @@ phrase := '"' 任意文本 '"' (内部支持转义:\" → 字面引号,
|
|||||||
| M4 GUI 框架 | 已完成 | 主题/图标接入、主窗口布局(顶部区 + TabControl + 全窗口错误页体系)、设置/关于模态;全 UI 文本资源化(硬规则生效)、语言设置项、RTL/FlowDirection 检测;亮/暗/跟随系统主题基础设施与设置项、ThemeDictionaries 颜色硬规则 | 人工 |
|
| M4 GUI 框架 | 已完成 | 主题/图标接入、主窗口布局(顶部区 + TabControl + 全窗口错误页体系)、设置/关于模态;全 UI 文本资源化(硬规则生效)、语言设置项、RTL/FlowDirection 检测;亮/暗/跟随系统主题基础设施与设置项、ThemeDictionaries 颜色硬规则 | 人工 |
|
||||||
| **M4R v1.9 回补** | 已完成 | 见下方清单 | 本地 Windows build + test 通过;新增页面人工 |
|
| **M4R v1.9 回补** | 已完成 | 见下方清单 | 本地 Windows build + test 通过;新增页面人工 |
|
||||||
| M5 看板界面 | 已完成(待人工验收) | 看板 tab 全部交互与模态(含约束冲突模态内提示、卡片列表虚拟化、嵌套模态新建标签立即写库、写库后按最近成功表达式刷新) | 人工 |
|
| M5 看板界面 | 已完成(待人工验收) | 看板 tab 全部交互与模态(含约束冲突模态内提示、卡片列表虚拟化、嵌套模态新建标签立即写库、写库后按最近成功表达式刷新) | 人工 |
|
||||||
| M6 标签与搜索集成 | 待办 | 标签 tab、搜索执行与空列保留、导出接线(含文件名/时间戳 i18n 模式) | 人工 |
|
| M6 标签与搜索集成 | 已完成(待人工验收) | 标签 tab、搜索执行与空列保留、导出接线(含文件名/时间戳 i18n 模式) | 人工 |
|
||||||
| M7 发布与文档 | 待办 | 单一 workflow(4 RID 矩阵 build→test→artifact,osx-x64 用 Intel 运行器)文件就绪(结构自查)、根目录 USAGE.md、README(引用 USAGE.md)、人工验收清单(含语言切换重启生效、导出语言跟随界面、主题三态切换、app.json 多实例脏写四项) | workflow 结构自查通过;实际触发与产物验证视网络条件推迟 |
|
| M7 发布与文档 | 待办 | 单一 workflow(4 RID 矩阵 build→test→artifact,osx-x64 用 Intel 运行器)文件就绪(结构自查)、根目录 USAGE.md、README(引用 USAGE.md)、人工验收清单(含语言切换重启生效、导出语言跟随界面、主题三态切换、app.json 多实例脏写四项) | workflow 结构自查通过;实际触发与产物验证视网络条件推迟 |
|
||||||
|
|
||||||
**M4R 清单**(v1.9 对已完成里程碑的回补,进入 M5 前完成):
|
**M4R 清单**(v1.9 对已完成里程碑的回补,进入 M5 前完成):
|
||||||
|
|||||||
Reference in new issue
Block a user