feat: implement board tab with dialogs, virtualized card lists and refresh-after-write
- board: columns in id order, per-column sort via config, column width presets (240/320/480) and custom 160-720 - column header new-card and edit/delete menu; card menu delete and move-to-column - card editor as one unit of work with dirty check and discard confirmation; nested tag picker writes new tags immediately - schema constraint violations mapped to typed exceptions and shown inside the modal - dialog service with owner stack so nested modals center on their parent and get FlowDirection - repository: filtered GetCards, CountCards; view-model tests
This commit is contained in:
1 parent
84d08c6768
commit
e3f58ce150
41 files changed
+3004
-26
No files matched your search
@@ -1,5 +1,7 @@
|
|||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
using YKanBan.Models;
|
using YKanBan.Models;
|
||||||
|
using YKanBan.Search;
|
||||||
|
using YKanBan.Storage;
|
||||||
using YKanBan.Storage.Workspace;
|
using YKanBan.Storage.Workspace;
|
||||||
using YKanBan.Tests.TestUtilities;
|
using YKanBan.Tests.TestUtilities;
|
||||||
|
|
||||||
@@ -253,8 +255,87 @@ public class WorkspaceRepositoryTests
|
|||||||
public void AddTagWithDuplicateNameIsRejected()
|
public void AddTagWithDuplicateNameIsRejected()
|
||||||
{
|
{
|
||||||
using var fixture = new RepositoryFixture();
|
using var fixture = new RepositoryFixture();
|
||||||
Assert.ThrowsExactly<SqliteException>(() =>
|
DuplicateNameException exception = Assert.ThrowsExactly<DuplicateNameException>(() =>
|
||||||
fixture.Repository.AddTag("bug", RgbColor.Parse("#123456"), ""));
|
fixture.Repository.AddTag("BUG", RgbColor.Parse("#123456"), ""));
|
||||||
|
Assert.AreEqual(NamedEntity.Tag, exception.Entity);
|
||||||
|
Assert.AreEqual("BUG", exception.Name);
|
||||||
|
Assert.AreEqual(2, fixture.Repository.GetTags().Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void AddTagWithEmptyNameIsRejected()
|
||||||
|
{
|
||||||
|
using var fixture = new RepositoryFixture();
|
||||||
|
NameRequiredException exception = Assert.ThrowsExactly<NameRequiredException>(() =>
|
||||||
|
fixture.Repository.AddTag("", RgbColor.Parse("#123456"), ""));
|
||||||
|
Assert.AreEqual(NamedEntity.Tag, exception.Entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void UpdateTagToExistingNameIsRejectedAndKeepsTheRow()
|
||||||
|
{
|
||||||
|
using var fixture = new RepositoryFixture();
|
||||||
|
Assert.ThrowsExactly<DuplicateNameException>(() =>
|
||||||
|
fixture.Repository.UpdateTag(fixture.UiTagId, "Bug", RgbColor.Parse("#00ff00"), ""));
|
||||||
|
Assert.AreEqual("ui", fixture.Repository.GetTags().Single(tag => tag.Id == fixture.UiTagId).Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void AddColumnWithDuplicateTitleIsRejectedCaseInsensitively()
|
||||||
|
{
|
||||||
|
using var fixture = new RepositoryFixture();
|
||||||
|
DuplicateNameException exception = Assert.ThrowsExactly<DuplicateNameException>(() =>
|
||||||
|
fixture.Repository.AddColumn("COL", ""));
|
||||||
|
Assert.AreEqual(NamedEntity.Column, exception.Entity);
|
||||||
|
Assert.AreEqual(1, fixture.Repository.GetColumns().Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void AddColumnWithEmptyTitleIsRejected()
|
||||||
|
{
|
||||||
|
using var fixture = new RepositoryFixture();
|
||||||
|
NameRequiredException exception = Assert.ThrowsExactly<NameRequiredException>(() =>
|
||||||
|
fixture.Repository.AddColumn("", "description"));
|
||||||
|
Assert.AreEqual(NamedEntity.Column, exception.Entity);
|
||||||
|
Assert.AreEqual(1, fixture.Repository.GetColumns().Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void UpdateColumnConstraintViolationsAreTyped()
|
||||||
|
{
|
||||||
|
using var fixture = new RepositoryFixture();
|
||||||
|
ColumnModel other = fixture.Repository.AddColumn("Other", "");
|
||||||
|
|
||||||
|
Assert.ThrowsExactly<DuplicateNameException>(() => fixture.Repository.UpdateColumn(other.Id, "col", ""));
|
||||||
|
Assert.ThrowsExactly<NameRequiredException>(() => fixture.Repository.UpdateColumn(other.Id, "", ""));
|
||||||
|
Assert.AreEqual("Other", fixture.Repository.GetColumn(other.Id)!.Title);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void GetCardsWithFilterReturnsMatchesWithAllOfTheirTags()
|
||||||
|
{
|
||||||
|
using var fixture = new RepositoryFixture();
|
||||||
|
fixture.Repository.AddCard(fixture.ColumnId, "Other", "", []);
|
||||||
|
|
||||||
|
// Filtering by one tag must still load both tags of the matching card.
|
||||||
|
CompiledSearch filter = SearchSqlCompiler.Compile(SearchQueryParser.Parse("tag:ui")!);
|
||||||
|
IReadOnlyList<CardModel> cards = fixture.Repository.GetCards(filter);
|
||||||
|
|
||||||
|
Assert.AreEqual(1, cards.Count);
|
||||||
|
Assert.AreEqual(fixture.CardId, cards[0].Id);
|
||||||
|
CollectionAssert.AreEqual(new[] { "bug", "ui" }, cards[0].Tags.Select(tag => tag.Name).ToArray());
|
||||||
|
Assert.AreEqual(2, fixture.Repository.GetCards(null).Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void CountCardsCountsPerColumn()
|
||||||
|
{
|
||||||
|
using var fixture = new RepositoryFixture();
|
||||||
|
ColumnModel empty = fixture.Repository.AddColumn("Empty", "");
|
||||||
|
fixture.Repository.AddCard(fixture.ColumnId, "", "", []);
|
||||||
|
|
||||||
|
Assert.AreEqual(2L, fixture.Repository.CountCards(fixture.ColumnId));
|
||||||
|
Assert.AreEqual(0L, fixture.Repository.CountCards(empty.Id));
|
||||||
}
|
}
|
||||||
|
|
||||||
[TestMethod]
|
[TestMethod]
|
||||||
|
|||||||
@@ -0,0 +1,393 @@
|
|||||||
|
using YKanBan.Models;
|
||||||
|
using YKanBan.Search;
|
||||||
|
using YKanBan.Storage;
|
||||||
|
using YKanBan.Storage.AppData;
|
||||||
|
using YKanBan.Storage.Workspace;
|
||||||
|
using YKanBan.Tests.TestUtilities;
|
||||||
|
using YKanBan.ViewModels.Board;
|
||||||
|
using YKanBan.ViewModels.Dialogs;
|
||||||
|
|
||||||
|
namespace YKanBan.Tests.ViewModels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// View-model tests of the board and its dialogs against a real temporary
|
||||||
|
/// workspace, with a scripted dialog service standing in for the modal windows.
|
||||||
|
/// </summary>
|
||||||
|
[TestClass]
|
||||||
|
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
|
||||||
|
{
|
||||||
|
public TempDirectory Directory { get; } = new();
|
||||||
|
|
||||||
|
public WorkspaceRepository Repository { get; }
|
||||||
|
|
||||||
|
public AppConfig Config { get; } = new();
|
||||||
|
|
||||||
|
public ScriptedDialogService Dialogs { get; } = new();
|
||||||
|
|
||||||
|
public Fixture()
|
||||||
|
{
|
||||||
|
WorkspaceInitializer.Initialize(Directory.FullPath);
|
||||||
|
Repository = new WorkspaceRepository(Directory.FullPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public BoardViewModel CreateBoard() => new(Repository, Config, Dialogs);
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Repository.Dispose();
|
||||||
|
Directory.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void RefreshListsColumnsInIdOrderAndSortsCardsWithinEachColumn()
|
||||||
|
{
|
||||||
|
using var fixture = new Fixture();
|
||||||
|
ColumnModel first = fixture.Repository.AddColumn("A", "");
|
||||||
|
ColumnModel second = fixture.Repository.AddColumn("B", "");
|
||||||
|
fixture.Repository.AddCard(first.Id, "zeta", "", []);
|
||||||
|
fixture.Repository.AddCard(first.Id, "alpha", "", []);
|
||||||
|
fixture.Repository.AddCard(second.Id, "x", "", []);
|
||||||
|
|
||||||
|
fixture.Config.Sort.Card = CardSortOption.Title;
|
||||||
|
BoardViewModel board = fixture.CreateBoard();
|
||||||
|
|
||||||
|
CollectionAssert.AreEqual(new[] { "A", "B" }, board.Columns.Select(column => column.Title).ToArray());
|
||||||
|
CollectionAssert.AreEqual(new[] { "alpha", "zeta" }, board.Columns[0].Cards.Select(card => card.Title).ToArray());
|
||||||
|
Assert.AreEqual(1, board.Columns[1].CardCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void ChangingTheSortPersistsItToTheConfigurationAndResorts()
|
||||||
|
{
|
||||||
|
using var fixture = new Fixture();
|
||||||
|
ColumnModel column = fixture.Repository.AddColumn("A", "");
|
||||||
|
fixture.Repository.AddCard(column.Id, "b", "", []);
|
||||||
|
fixture.Repository.AddCard(column.Id, "a", "", []);
|
||||||
|
BoardViewModel board = fixture.CreateBoard();
|
||||||
|
CollectionAssert.AreEqual(new[] { "b", "a" }, board.Columns[0].Cards.Select(card => card.Title).ToArray());
|
||||||
|
|
||||||
|
board.SelectedSort = board.SortOptions.Single(option => (CardSortOption)option.Value == CardSortOption.Title);
|
||||||
|
|
||||||
|
Assert.AreEqual(CardSortOption.Title, fixture.Config.Sort.Card);
|
||||||
|
CollectionAssert.AreEqual(new[] { "a", "b" }, board.Columns[0].Cards.Select(card => card.Title).ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public async Task WritesRefreshWithTheActiveFilterAndKeepEmptyColumns()
|
||||||
|
{
|
||||||
|
using var fixture = new Fixture();
|
||||||
|
ColumnModel todo = fixture.Repository.AddColumn("Todo", "");
|
||||||
|
ColumnModel done = fixture.Repository.AddColumn("Done", "");
|
||||||
|
CardModel match = fixture.Repository.AddCard(todo.Id, "match me", "", []);
|
||||||
|
fixture.Repository.AddCard(todo.Id, "other", "", []);
|
||||||
|
|
||||||
|
BoardViewModel board = fixture.CreateBoard();
|
||||||
|
board.ActiveFilter = SearchSqlCompiler.Compile(SearchQueryParser.Parse("match")!);
|
||||||
|
board.Refresh();
|
||||||
|
Assert.AreEqual(1, board.Columns[0].CardCount);
|
||||||
|
|
||||||
|
// A move is a write: the board re-queries with the active filter.
|
||||||
|
CardViewModel card = board.Columns[0].Cards.Single();
|
||||||
|
MoveTargetViewModel target = card.MoveTargets.Single(entry => entry.ColumnId == done.Id);
|
||||||
|
Assert.IsFalse(card.MoveTargets.Single(entry => entry.ColumnId == todo.Id).IsEnabled);
|
||||||
|
await target.Command.ExecuteAsync(target.ColumnId);
|
||||||
|
|
||||||
|
Assert.AreEqual(2, board.Columns.Count, "Columns emptied by the filter stay on the board.");
|
||||||
|
Assert.AreEqual(0, board.Columns[0].CardCount);
|
||||||
|
Assert.AreEqual(match.Id, board.Columns[1].Cards.Single().Card.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public async Task DeclinedColumnDeletionKeepsTheColumnAndShowsItsCardCount()
|
||||||
|
{
|
||||||
|
using var fixture = new Fixture();
|
||||||
|
ColumnModel column = fixture.Repository.AddColumn("A", "");
|
||||||
|
fixture.Repository.AddCard(column.Id, "", "", []);
|
||||||
|
fixture.Repository.AddCard(column.Id, "", "", []);
|
||||||
|
BoardViewModel board = fixture.CreateBoard();
|
||||||
|
fixture.Dialogs.Script = AnswerConfirmations(false);
|
||||||
|
|
||||||
|
await board.Columns[0].DeleteCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
var confirmation = (ConfirmDialogViewModel)fixture.Dialogs.Shown.Single();
|
||||||
|
StringAssert.Contains(confirmation.Message, "2");
|
||||||
|
Assert.AreEqual(1, fixture.Repository.GetColumns().Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public async Task ColumnDeletionWithoutConfirmationDeletesImmediately()
|
||||||
|
{
|
||||||
|
using var fixture = new Fixture();
|
||||||
|
fixture.Repository.AddColumn("A", "");
|
||||||
|
fixture.Config.Confirmations.DeleteColumn = false;
|
||||||
|
BoardViewModel board = fixture.CreateBoard();
|
||||||
|
|
||||||
|
await board.Columns[0].DeleteCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
Assert.AreEqual(0, fixture.Dialogs.Shown.Count);
|
||||||
|
Assert.AreEqual(0, board.Columns.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public async Task ConfirmedCardDeletionDeletesTheCard()
|
||||||
|
{
|
||||||
|
using var fixture = new Fixture();
|
||||||
|
ColumnModel column = fixture.Repository.AddColumn("A", "");
|
||||||
|
fixture.Repository.AddCard(column.Id, "gone", "", []);
|
||||||
|
BoardViewModel board = fixture.CreateBoard();
|
||||||
|
fixture.Dialogs.Script = AnswerConfirmations(true);
|
||||||
|
|
||||||
|
await board.Columns[0].Cards[0].DeleteCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
Assert.AreEqual(0, board.Columns[0].CardCount);
|
||||||
|
Assert.AreEqual(0, fixture.Repository.GetCards().Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public async Task DuplicateColumnTitleIsReportedInsideTheDialog()
|
||||||
|
{
|
||||||
|
using var fixture = new Fixture();
|
||||||
|
fixture.Repository.AddColumn("Todo", "");
|
||||||
|
BoardViewModel board = fixture.CreateBoard();
|
||||||
|
ColumnEditorDialogViewModel? editor = null;
|
||||||
|
bool closed = false;
|
||||||
|
fixture.Dialogs.Script = dialog =>
|
||||||
|
{
|
||||||
|
editor = (ColumnEditorDialogViewModel)dialog;
|
||||||
|
editor.CloseRequested += (_, _) => closed = true;
|
||||||
|
editor.ColumnTitle = "TODO";
|
||||||
|
editor.ConfirmCommand.Execute(null);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
};
|
||||||
|
|
||||||
|
await board.AddColumnCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
Assert.IsNotNull(editor);
|
||||||
|
Assert.IsFalse(closed, "The dialog stays open on a constraint violation.");
|
||||||
|
Assert.IsTrue(editor.HasError);
|
||||||
|
StringAssert.Contains(editor.ErrorMessage, "TODO");
|
||||||
|
Assert.AreEqual(1, fixture.Repository.GetColumns().Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public async Task NewCardIsWrittenWithItsTagsOnConfirmation()
|
||||||
|
{
|
||||||
|
using var fixture = new Fixture();
|
||||||
|
ColumnModel column = fixture.Repository.AddColumn("A", "");
|
||||||
|
TagModel bug = fixture.Repository.AddTag("bug", new RgbColor(255, 0, 0), "");
|
||||||
|
BoardViewModel board = fixture.CreateBoard();
|
||||||
|
fixture.Dialogs.Script = async dialog =>
|
||||||
|
{
|
||||||
|
switch (dialog)
|
||||||
|
{
|
||||||
|
case CardEditorDialogViewModel editor:
|
||||||
|
editor.CardTitle = "Fix it";
|
||||||
|
editor.Content = "Details";
|
||||||
|
await editor.AddTagCommand.ExecuteAsync(null);
|
||||||
|
editor.ConfirmCommand.Execute(null);
|
||||||
|
break;
|
||||||
|
case TagPickerDialogViewModel picker:
|
||||||
|
picker.PickCommand.Execute(picker.FilteredTags.Single());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await board.Columns[0].AddCardCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
CardModel card = fixture.Repository.GetCards().Single();
|
||||||
|
Assert.AreEqual("Fix it", card.Title);
|
||||||
|
Assert.AreEqual("Details", card.Content);
|
||||||
|
Assert.AreEqual(bug.Id, card.Tags.Single().Id);
|
||||||
|
Assert.AreEqual(card.Id, board.Columns[0].Cards.Single().Card.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void UnchangedEditConfirmationWritesNothing()
|
||||||
|
{
|
||||||
|
using var fixture = new Fixture();
|
||||||
|
ColumnModel column = fixture.Repository.AddColumn("A", "");
|
||||||
|
CardModel card = fixture.Repository.AddCard(column.Id, "t", "c", []);
|
||||||
|
using (var connection = SqliteTestHelper.OpenWorkspace(fixture.Directory.FullPath))
|
||||||
|
{
|
||||||
|
SqliteTestHelper.Exec(connection, "UPDATE cards SET updated_at = 100;");
|
||||||
|
}
|
||||||
|
card = fixture.Repository.GetCard(card.Id)!;
|
||||||
|
|
||||||
|
var editor = CardEditorDialogViewModel.ForExistingCard(fixture.Repository, fixture.Dialogs, card, confirmDiscard: true);
|
||||||
|
editor.CardTitle = "changed";
|
||||||
|
editor.CardTitle = "t";
|
||||||
|
Assert.IsFalse(editor.IsDirty);
|
||||||
|
editor.ConfirmCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.AreEqual(100, fixture.Repository.GetCard(card.Id)!.UpdatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public async Task DirtyCancelAsksAndStaysOpenWhenDeclined()
|
||||||
|
{
|
||||||
|
using var fixture = new Fixture();
|
||||||
|
ColumnModel column = fixture.Repository.AddColumn("A", "");
|
||||||
|
var editor = CardEditorDialogViewModel.ForNewCard(fixture.Repository, fixture.Dialogs, column.Id, confirmDiscard: true);
|
||||||
|
bool? closedWith = null;
|
||||||
|
editor.CloseRequested += (_, result) => closedWith = result;
|
||||||
|
editor.Content = "draft";
|
||||||
|
|
||||||
|
fixture.Dialogs.Script = AnswerConfirmations(false);
|
||||||
|
await editor.RequestCancelAsync();
|
||||||
|
Assert.IsNull(closedWith, "Declining the discard keeps the editor open.");
|
||||||
|
|
||||||
|
fixture.Dialogs.Script = AnswerConfirmations(true);
|
||||||
|
await editor.RequestCancelAsync();
|
||||||
|
Assert.IsFalse(closedWith);
|
||||||
|
Assert.AreEqual(0, fixture.Repository.GetCards().Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public async Task CleanCancelClosesWithoutAsking()
|
||||||
|
{
|
||||||
|
using var fixture = new Fixture();
|
||||||
|
ColumnModel column = fixture.Repository.AddColumn("A", "");
|
||||||
|
var editor = CardEditorDialogViewModel.ForNewCard(fixture.Repository, fixture.Dialogs, column.Id, confirmDiscard: true);
|
||||||
|
bool? closedWith = null;
|
||||||
|
editor.CloseRequested += (_, result) => closedWith = result;
|
||||||
|
|
||||||
|
await editor.RequestCancelAsync();
|
||||||
|
|
||||||
|
Assert.IsFalse(closedWith);
|
||||||
|
Assert.AreEqual(0, fixture.Dialogs.Shown.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public async Task TagCreatedInThePickerSurvivesACancelledCardEdit()
|
||||||
|
{
|
||||||
|
using var fixture = new Fixture();
|
||||||
|
ColumnModel column = fixture.Repository.AddColumn("A", "");
|
||||||
|
var editor = CardEditorDialogViewModel.ForNewCard(fixture.Repository, fixture.Dialogs, column.Id, confirmDiscard: false);
|
||||||
|
fixture.Dialogs.Script = dialog =>
|
||||||
|
{
|
||||||
|
if (dialog is TagPickerDialogViewModel picker)
|
||||||
|
{
|
||||||
|
picker.NewName = "fresh";
|
||||||
|
picker.NewColor = "#123456";
|
||||||
|
picker.CreateCommand.Execute(null);
|
||||||
|
}
|
||||||
|
return Task.CompletedTask;
|
||||||
|
};
|
||||||
|
|
||||||
|
await editor.AddTagCommand.ExecuteAsync(null);
|
||||||
|
Assert.AreEqual("fresh", editor.Tags.Single().Name);
|
||||||
|
await editor.RequestCancelAsync();
|
||||||
|
|
||||||
|
Assert.AreEqual("fresh", fixture.Repository.GetTags().Single().Name);
|
||||||
|
Assert.AreEqual(0, fixture.Repository.GetCards().Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void TagPickerReportsInvalidColorAndDuplicateNameWithoutClosing()
|
||||||
|
{
|
||||||
|
using var fixture = new Fixture();
|
||||||
|
fixture.Repository.AddTag("bug", new RgbColor(1, 2, 3), "");
|
||||||
|
var picker = new TagPickerDialogViewModel(fixture.Repository, []);
|
||||||
|
bool closed = false;
|
||||||
|
picker.CloseRequested += (_, _) => closed = true;
|
||||||
|
|
||||||
|
picker.NewName = "new";
|
||||||
|
picker.NewColor = "red";
|
||||||
|
picker.CreateCommand.Execute(null);
|
||||||
|
Assert.AreEqual(YKanBan.Resources.Error_Tag_InvalidColor, picker.ErrorMessage);
|
||||||
|
|
||||||
|
picker.NewName = "BUG";
|
||||||
|
picker.NewColor = "#ff0000";
|
||||||
|
picker.CreateCommand.Execute(null);
|
||||||
|
StringAssert.Contains(picker.ErrorMessage, "BUG");
|
||||||
|
|
||||||
|
Assert.IsFalse(closed);
|
||||||
|
Assert.AreEqual(1, fixture.Repository.GetTags().Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void TagPickerHidesAssignedTagsAndFiltersCaseInsensitively()
|
||||||
|
{
|
||||||
|
using var fixture = new Fixture();
|
||||||
|
TagModel bug = fixture.Repository.AddTag("Bug", new RgbColor(1, 2, 3), "");
|
||||||
|
fixture.Repository.AddTag("UI", new RgbColor(1, 2, 3), "");
|
||||||
|
fixture.Repository.AddTag("Build", new RgbColor(1, 2, 3), "");
|
||||||
|
var picker = new TagPickerDialogViewModel(fixture.Repository, [bug.Id]);
|
||||||
|
|
||||||
|
picker.FilterText = "bu";
|
||||||
|
CollectionAssert.AreEqual(new[] { "Build" }, picker.FilteredTags.Select(tag => tag.Name).ToArray());
|
||||||
|
|
||||||
|
picker.FilterText = "zzz";
|
||||||
|
Assert.IsTrue(picker.NoMatches);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void ColumnWidthDialogCommitsPresetsAndClampsCustomPixels()
|
||||||
|
{
|
||||||
|
var settings = new ColumnWidthSettings();
|
||||||
|
var dialog = new ColumnWidthDialogViewModel(settings) { IsStandard = false, IsCustom = true, CustomPixels = 5000 };
|
||||||
|
dialog.ConfirmCommand.Execute(null);
|
||||||
|
Assert.AreEqual(ColumnWidthPreset.Custom, settings.Preset);
|
||||||
|
Assert.AreEqual(AppConfig.MaxColumnPixels, settings.CustomPixels);
|
||||||
|
Assert.AreEqual(AppConfig.MaxColumnPixels, ColumnWidths.Resolve(settings));
|
||||||
|
|
||||||
|
dialog = new ColumnWidthDialogViewModel(settings) { IsCustom = false, IsWide = true };
|
||||||
|
dialog.ConfirmCommand.Execute(null);
|
||||||
|
Assert.AreEqual(ColumnWidthPreset.Wide, settings.Preset);
|
||||||
|
Assert.AreEqual(ColumnWidths.Wide, ColumnWidths.Resolve(settings));
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void CardHeaderAndExcerptFollowTheDisplayRules()
|
||||||
|
{
|
||||||
|
var untitled = new CardModel(61, 1, "", "", 0, 0, []);
|
||||||
|
var titled = untitled with { Title = "Foo bar" };
|
||||||
|
Assert.AreEqual("#61", CardViewModel.FormatHeader(untitled));
|
||||||
|
Assert.AreEqual("#61 Foo bar", CardViewModel.FormatHeader(titled));
|
||||||
|
|
||||||
|
Assert.AreEqual("short", CardViewModel.MakeExcerpt(" short \n"));
|
||||||
|
string longContent = string.Concat(Enumerable.Repeat("😀", CardViewModel.ExcerptLength + 5));
|
||||||
|
string excerpt = CardViewModel.MakeExcerpt(longContent);
|
||||||
|
Assert.IsTrue(excerpt.EndsWith('…'));
|
||||||
|
Assert.AreEqual((CardViewModel.ExcerptLength * 2) + 1, excerpt.Length, "Surrogate pairs are kept whole.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,10 +26,20 @@
|
|||||||
<ResourceDictionary x:Key="Light">
|
<ResourceDictionary x:Key="Light">
|
||||||
<SolidColorBrush x:Key="TopBarBackgroundBrush" Color="#F6F7F9"/>
|
<SolidColorBrush x:Key="TopBarBackgroundBrush" Color="#F6F7F9"/>
|
||||||
<SolidColorBrush x:Key="TopBarBorderBrush" Color="#E2E4EA"/>
|
<SolidColorBrush x:Key="TopBarBorderBrush" Color="#E2E4EA"/>
|
||||||
|
<SolidColorBrush x:Key="PanelBorderBrush" Color="#E2E4EA"/>
|
||||||
|
<SolidColorBrush x:Key="ColumnBackgroundBrush" Color="#F3F4F7"/>
|
||||||
|
<SolidColorBrush x:Key="CardBackgroundBrush" Color="#FFFFFF"/>
|
||||||
|
<SolidColorBrush x:Key="CardForegroundBrush" Color="#1C1F23"/>
|
||||||
|
<SolidColorBrush x:Key="ErrorTextBrush" Color="#D52515"/>
|
||||||
</ResourceDictionary>
|
</ResourceDictionary>
|
||||||
<ResourceDictionary x:Key="Dark">
|
<ResourceDictionary x:Key="Dark">
|
||||||
<SolidColorBrush x:Key="TopBarBackgroundBrush" Color="#1C1E24"/>
|
<SolidColorBrush x:Key="TopBarBackgroundBrush" Color="#1C1E24"/>
|
||||||
<SolidColorBrush x:Key="TopBarBorderBrush" Color="#2C2F37"/>
|
<SolidColorBrush x:Key="TopBarBorderBrush" Color="#2C2F37"/>
|
||||||
|
<SolidColorBrush x:Key="PanelBorderBrush" Color="#33363F"/>
|
||||||
|
<SolidColorBrush x:Key="ColumnBackgroundBrush" Color="#1A1C21"/>
|
||||||
|
<SolidColorBrush x:Key="CardBackgroundBrush" Color="#25282F"/>
|
||||||
|
<SolidColorBrush x:Key="CardForegroundBrush" Color="#E6E8EC"/>
|
||||||
|
<SolidColorBrush x:Key="ErrorTextBrush" Color="#FC725A"/>
|
||||||
</ResourceDictionary>
|
</ResourceDictionary>
|
||||||
</ResourceDictionary.ThemeDictionaries>
|
</ResourceDictionary.ThemeDictionaries>
|
||||||
</ResourceDictionary>
|
</ResourceDictionary>
|
||||||
|
|||||||
@@ -58,12 +58,15 @@ public partial class App : Application
|
|||||||
: FlowDirection.LeftToRight;
|
: FlowDirection.LeftToRight;
|
||||||
ApplyTheme(Services.Config.Theme);
|
ApplyTheme(Services.Config.Theme);
|
||||||
|
|
||||||
var viewModel = new MainWindowViewModel(Services, desktop.Args ?? []);
|
var dialogs = new DialogService();
|
||||||
desktop.MainWindow = new MainWindow
|
var viewModel = new MainWindowViewModel(Services, dialogs, desktop.Args ?? []);
|
||||||
|
var mainWindow = new MainWindow
|
||||||
{
|
{
|
||||||
DataContext = viewModel,
|
DataContext = viewModel,
|
||||||
FlowDirection = UiFlowDirection,
|
FlowDirection = UiFlowDirection,
|
||||||
};
|
};
|
||||||
|
dialogs.Attach(mainWindow);
|
||||||
|
desktop.MainWindow = mainWindow;
|
||||||
}
|
}
|
||||||
|
|
||||||
base.OnFrameworkInitializationCompleted();
|
base.OnFrameworkInitializationCompleted();
|
||||||
|
|||||||
@@ -184,4 +184,160 @@
|
|||||||
<data name="Export_TagsTableHeader" xml:space="preserve">
|
<data name="Export_TagsTableHeader" xml:space="preserve">
|
||||||
<value>| Name | Color | Description | Usage |</value>
|
<value>| Name | Color | Description | Usage |</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="Common_Delete" xml:space="preserve">
|
||||||
|
<value>Delete</value>
|
||||||
|
</data>
|
||||||
|
<data name="Common_Discard" xml:space="preserve">
|
||||||
|
<value>Discard</value>
|
||||||
|
</data>
|
||||||
|
<data name="Common_TimePattern" xml:space="preserve">
|
||||||
|
<value>yyyy-MM-dd HH:mm</value>
|
||||||
|
</data>
|
||||||
|
<data name="Board_AddColumn" xml:space="preserve">
|
||||||
|
<value>New column</value>
|
||||||
|
</data>
|
||||||
|
<data name="Board_ColumnWidth" xml:space="preserve">
|
||||||
|
<value>Column width</value>
|
||||||
|
</data>
|
||||||
|
<data name="Board_Sort" xml:space="preserve">
|
||||||
|
<value>Card order</value>
|
||||||
|
</data>
|
||||||
|
<data name="Board_AddCard" xml:space="preserve">
|
||||||
|
<value>New card</value>
|
||||||
|
</data>
|
||||||
|
<data name="Sort_Id" xml:space="preserve">
|
||||||
|
<value>By id</value>
|
||||||
|
</data>
|
||||||
|
<data name="Sort_UpdatedAt" xml:space="preserve">
|
||||||
|
<value>Recently modified</value>
|
||||||
|
</data>
|
||||||
|
<data name="Sort_Title" xml:space="preserve">
|
||||||
|
<value>By title</value>
|
||||||
|
</data>
|
||||||
|
<data name="Menu_More" xml:space="preserve">
|
||||||
|
<value>More</value>
|
||||||
|
</data>
|
||||||
|
<data name="Menu_Edit" xml:space="preserve">
|
||||||
|
<value>Edit</value>
|
||||||
|
</data>
|
||||||
|
<data name="Menu_Delete" xml:space="preserve">
|
||||||
|
<value>Delete</value>
|
||||||
|
</data>
|
||||||
|
<data name="Menu_MoveTo" xml:space="preserve">
|
||||||
|
<value>Move to column</value>
|
||||||
|
</data>
|
||||||
|
<data name="Field_Title" xml:space="preserve">
|
||||||
|
<value>Title</value>
|
||||||
|
</data>
|
||||||
|
<data name="Field_Description" xml:space="preserve">
|
||||||
|
<value>Description</value>
|
||||||
|
</data>
|
||||||
|
<data name="Field_Content" xml:space="preserve">
|
||||||
|
<value>Content</value>
|
||||||
|
</data>
|
||||||
|
<data name="Field_Tags" xml:space="preserve">
|
||||||
|
<value>Tags</value>
|
||||||
|
</data>
|
||||||
|
<data name="Field_Name" xml:space="preserve">
|
||||||
|
<value>Name</value>
|
||||||
|
</data>
|
||||||
|
<data name="Field_Color" xml:space="preserve">
|
||||||
|
<value>#RRGGBB</value>
|
||||||
|
</data>
|
||||||
|
<data name="ColumnEditor_TitleNew" xml:space="preserve">
|
||||||
|
<value>New column</value>
|
||||||
|
</data>
|
||||||
|
<data name="ColumnEditor_TitleEdit" xml:space="preserve">
|
||||||
|
<value>Edit column</value>
|
||||||
|
</data>
|
||||||
|
<data name="ColumnWidth_Title" xml:space="preserve">
|
||||||
|
<value>Column width</value>
|
||||||
|
</data>
|
||||||
|
<data name="ColumnWidth_Narrow" xml:space="preserve">
|
||||||
|
<value>Narrow ({0} px)</value>
|
||||||
|
</data>
|
||||||
|
<data name="ColumnWidth_Standard" xml:space="preserve">
|
||||||
|
<value>Standard ({0} px)</value>
|
||||||
|
</data>
|
||||||
|
<data name="ColumnWidth_Wide" xml:space="preserve">
|
||||||
|
<value>Wide ({0} px)</value>
|
||||||
|
</data>
|
||||||
|
<data name="ColumnWidth_Custom" xml:space="preserve">
|
||||||
|
<value>Custom</value>
|
||||||
|
</data>
|
||||||
|
<data name="ColumnWidth_Range" xml:space="preserve">
|
||||||
|
<value>{0}–{1} px</value>
|
||||||
|
</data>
|
||||||
|
<data name="CardEditor_TitleNew" xml:space="preserve">
|
||||||
|
<value>New card</value>
|
||||||
|
</data>
|
||||||
|
<data name="CardEditor_TitleEdit" xml:space="preserve">
|
||||||
|
<value>Edit card</value>
|
||||||
|
</data>
|
||||||
|
<data name="CardEditor_TitleWatermark" xml:space="preserve">
|
||||||
|
<value>No title</value>
|
||||||
|
</data>
|
||||||
|
<data name="CardEditor_Created" xml:space="preserve">
|
||||||
|
<value>Created {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="CardEditor_Modified" xml:space="preserve">
|
||||||
|
<value>Modified {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="CardEditor_AddTag" xml:space="preserve">
|
||||||
|
<value>Add tag</value>
|
||||||
|
</data>
|
||||||
|
<data name="CardEditor_RemoveTag" xml:space="preserve">
|
||||||
|
<value>Remove tag</value>
|
||||||
|
</data>
|
||||||
|
<data name="TagPicker_Title" xml:space="preserve">
|
||||||
|
<value>Add tag</value>
|
||||||
|
</data>
|
||||||
|
<data name="TagPicker_FilterWatermark" xml:space="preserve">
|
||||||
|
<value>Filter tags</value>
|
||||||
|
</data>
|
||||||
|
<data name="TagPicker_NoMatches" xml:space="preserve">
|
||||||
|
<value>No matching tags</value>
|
||||||
|
</data>
|
||||||
|
<data name="TagPicker_NewTag" xml:space="preserve">
|
||||||
|
<value>New tag</value>
|
||||||
|
</data>
|
||||||
|
<data name="TagPicker_Create" xml:space="preserve">
|
||||||
|
<value>Create and add</value>
|
||||||
|
</data>
|
||||||
|
<data name="Confirm_Title" xml:space="preserve">
|
||||||
|
<value>Confirm</value>
|
||||||
|
</data>
|
||||||
|
<data name="Confirm_DeleteCard" xml:space="preserve">
|
||||||
|
<value>Delete card "{0}"?</value>
|
||||||
|
</data>
|
||||||
|
<data name="Confirm_DeleteColumn" xml:space="preserve">
|
||||||
|
<value>Delete column "{0}" and its {1} card(s)?</value>
|
||||||
|
</data>
|
||||||
|
<data name="Confirm_DiscardEdit" xml:space="preserve">
|
||||||
|
<value>Discard the unsaved changes?</value>
|
||||||
|
</data>
|
||||||
|
<data name="Error_Column_TitleRequired" xml:space="preserve">
|
||||||
|
<value>The column title is required.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Error_Column_Duplicate" xml:space="preserve">
|
||||||
|
<value>A column named "{0}" already exists (names are case-insensitive).</value>
|
||||||
|
</data>
|
||||||
|
<data name="Error_Tag_NameRequired" xml:space="preserve">
|
||||||
|
<value>The tag name is required.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Error_Tag_Duplicate" xml:space="preserve">
|
||||||
|
<value>A tag named "{0}" already exists (names are case-insensitive).</value>
|
||||||
|
</data>
|
||||||
|
<data name="Error_Tag_InvalidColor" xml:space="preserve">
|
||||||
|
<value>The color must be written as #RRGGBB.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Error_SaveFailed" xml:space="preserve">
|
||||||
|
<value>Saving failed: {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="Error_Operation_Title" xml:space="preserve">
|
||||||
|
<value>Operation failed</value>
|
||||||
|
</data>
|
||||||
|
<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>
|
||||||
|
</data>
|
||||||
</root>
|
</root>
|
||||||
@@ -184,4 +184,160 @@
|
|||||||
<data name="Export_TagsTableHeader" xml:space="preserve">
|
<data name="Export_TagsTableHeader" xml:space="preserve">
|
||||||
<value>| 名称 | 颜色 | 描述 | 使用数 |</value>
|
<value>| 名称 | 颜色 | 描述 | 使用数 |</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="Common_Delete" xml:space="preserve">
|
||||||
|
<value>删除</value>
|
||||||
|
</data>
|
||||||
|
<data name="Common_Discard" xml:space="preserve">
|
||||||
|
<value>丢弃</value>
|
||||||
|
</data>
|
||||||
|
<data name="Common_TimePattern" xml:space="preserve">
|
||||||
|
<value>yyyy年M月d日 HH:mm</value>
|
||||||
|
</data>
|
||||||
|
<data name="Board_AddColumn" xml:space="preserve">
|
||||||
|
<value>新建列</value>
|
||||||
|
</data>
|
||||||
|
<data name="Board_ColumnWidth" xml:space="preserve">
|
||||||
|
<value>列宽</value>
|
||||||
|
</data>
|
||||||
|
<data name="Board_Sort" xml:space="preserve">
|
||||||
|
<value>卡片排序</value>
|
||||||
|
</data>
|
||||||
|
<data name="Board_AddCard" xml:space="preserve">
|
||||||
|
<value>新建卡片</value>
|
||||||
|
</data>
|
||||||
|
<data name="Sort_Id" xml:space="preserve">
|
||||||
|
<value>按 ID</value>
|
||||||
|
</data>
|
||||||
|
<data name="Sort_UpdatedAt" xml:space="preserve">
|
||||||
|
<value>最近修改</value>
|
||||||
|
</data>
|
||||||
|
<data name="Sort_Title" xml:space="preserve">
|
||||||
|
<value>按标题</value>
|
||||||
|
</data>
|
||||||
|
<data name="Menu_More" xml:space="preserve">
|
||||||
|
<value>更多</value>
|
||||||
|
</data>
|
||||||
|
<data name="Menu_Edit" xml:space="preserve">
|
||||||
|
<value>编辑</value>
|
||||||
|
</data>
|
||||||
|
<data name="Menu_Delete" xml:space="preserve">
|
||||||
|
<value>删除</value>
|
||||||
|
</data>
|
||||||
|
<data name="Menu_MoveTo" xml:space="preserve">
|
||||||
|
<value>移动到列</value>
|
||||||
|
</data>
|
||||||
|
<data name="Field_Title" xml:space="preserve">
|
||||||
|
<value>标题</value>
|
||||||
|
</data>
|
||||||
|
<data name="Field_Description" xml:space="preserve">
|
||||||
|
<value>描述</value>
|
||||||
|
</data>
|
||||||
|
<data name="Field_Content" xml:space="preserve">
|
||||||
|
<value>内容</value>
|
||||||
|
</data>
|
||||||
|
<data name="Field_Tags" xml:space="preserve">
|
||||||
|
<value>标签</value>
|
||||||
|
</data>
|
||||||
|
<data name="Field_Name" xml:space="preserve">
|
||||||
|
<value>名称</value>
|
||||||
|
</data>
|
||||||
|
<data name="Field_Color" xml:space="preserve">
|
||||||
|
<value>#RRGGBB</value>
|
||||||
|
</data>
|
||||||
|
<data name="ColumnEditor_TitleNew" xml:space="preserve">
|
||||||
|
<value>新建列</value>
|
||||||
|
</data>
|
||||||
|
<data name="ColumnEditor_TitleEdit" xml:space="preserve">
|
||||||
|
<value>编辑列</value>
|
||||||
|
</data>
|
||||||
|
<data name="ColumnWidth_Title" xml:space="preserve">
|
||||||
|
<value>列宽</value>
|
||||||
|
</data>
|
||||||
|
<data name="ColumnWidth_Narrow" xml:space="preserve">
|
||||||
|
<value>窄({0} px)</value>
|
||||||
|
</data>
|
||||||
|
<data name="ColumnWidth_Standard" xml:space="preserve">
|
||||||
|
<value>标准({0} px)</value>
|
||||||
|
</data>
|
||||||
|
<data name="ColumnWidth_Wide" xml:space="preserve">
|
||||||
|
<value>宽({0} px)</value>
|
||||||
|
</data>
|
||||||
|
<data name="ColumnWidth_Custom" xml:space="preserve">
|
||||||
|
<value>自定义</value>
|
||||||
|
</data>
|
||||||
|
<data name="ColumnWidth_Range" xml:space="preserve">
|
||||||
|
<value>{0}–{1} px</value>
|
||||||
|
</data>
|
||||||
|
<data name="CardEditor_TitleNew" xml:space="preserve">
|
||||||
|
<value>新建卡片</value>
|
||||||
|
</data>
|
||||||
|
<data name="CardEditor_TitleEdit" xml:space="preserve">
|
||||||
|
<value>编辑卡片</value>
|
||||||
|
</data>
|
||||||
|
<data name="CardEditor_TitleWatermark" xml:space="preserve">
|
||||||
|
<value>无标题</value>
|
||||||
|
</data>
|
||||||
|
<data name="CardEditor_Created" xml:space="preserve">
|
||||||
|
<value>创建于 {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="CardEditor_Modified" xml:space="preserve">
|
||||||
|
<value>修改于 {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="CardEditor_AddTag" xml:space="preserve">
|
||||||
|
<value>添加标签</value>
|
||||||
|
</data>
|
||||||
|
<data name="CardEditor_RemoveTag" xml:space="preserve">
|
||||||
|
<value>移除标签</value>
|
||||||
|
</data>
|
||||||
|
<data name="TagPicker_Title" xml:space="preserve">
|
||||||
|
<value>添加标签</value>
|
||||||
|
</data>
|
||||||
|
<data name="TagPicker_FilterWatermark" xml:space="preserve">
|
||||||
|
<value>筛选标签</value>
|
||||||
|
</data>
|
||||||
|
<data name="TagPicker_NoMatches" xml:space="preserve">
|
||||||
|
<value>没有匹配的标签</value>
|
||||||
|
</data>
|
||||||
|
<data name="TagPicker_NewTag" xml:space="preserve">
|
||||||
|
<value>新建标签</value>
|
||||||
|
</data>
|
||||||
|
<data name="TagPicker_Create" xml:space="preserve">
|
||||||
|
<value>新建并添加</value>
|
||||||
|
</data>
|
||||||
|
<data name="Confirm_Title" xml:space="preserve">
|
||||||
|
<value>确认</value>
|
||||||
|
</data>
|
||||||
|
<data name="Confirm_DeleteCard" xml:space="preserve">
|
||||||
|
<value>删除卡片“{0}”?</value>
|
||||||
|
</data>
|
||||||
|
<data name="Confirm_DeleteColumn" xml:space="preserve">
|
||||||
|
<value>删除列“{0}”及其中的 {1} 张卡片?</value>
|
||||||
|
</data>
|
||||||
|
<data name="Confirm_DiscardEdit" xml:space="preserve">
|
||||||
|
<value>放弃未保存的更改?</value>
|
||||||
|
</data>
|
||||||
|
<data name="Error_Column_TitleRequired" xml:space="preserve">
|
||||||
|
<value>列标题不能为空。</value>
|
||||||
|
</data>
|
||||||
|
<data name="Error_Column_Duplicate" xml:space="preserve">
|
||||||
|
<value>已存在名为“{0}”的列(名称不区分大小写)。</value>
|
||||||
|
</data>
|
||||||
|
<data name="Error_Tag_NameRequired" xml:space="preserve">
|
||||||
|
<value>标签名称不能为空。</value>
|
||||||
|
</data>
|
||||||
|
<data name="Error_Tag_Duplicate" xml:space="preserve">
|
||||||
|
<value>已存在名为“{0}”的标签(名称不区分大小写)。</value>
|
||||||
|
</data>
|
||||||
|
<data name="Error_Tag_InvalidColor" xml:space="preserve">
|
||||||
|
<value>颜色须为 #RRGGBB 格式。</value>
|
||||||
|
</data>
|
||||||
|
<data name="Error_SaveFailed" xml:space="preserve">
|
||||||
|
<value>保存失败:{0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="Error_Operation_Title" xml:space="preserve">
|
||||||
|
<value>操作失败</value>
|
||||||
|
</data>
|
||||||
|
<data name="Error_Operation_Message" xml:space="preserve">
|
||||||
|
<value>更改未能写入工作区,看板显示的是已保存的状态。</value>
|
||||||
|
</data>
|
||||||
</root>
|
</root>
|
||||||
@@ -136,3 +136,69 @@ public sealed class SchemaVersionException : YKanBanException
|
|||||||
SupportedVersion = supportedVersion;
|
SupportedVersion = supportedVersion;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The entities whose name/title is constrained by the schema to be non-empty
|
||||||
|
/// and table-unique.
|
||||||
|
/// </summary>
|
||||||
|
public enum NamedEntity
|
||||||
|
{
|
||||||
|
/// <summary>A board column (constraint on columns.title).</summary>
|
||||||
|
Column,
|
||||||
|
|
||||||
|
/// <summary>A tag (constraint on tags.name).</summary>
|
||||||
|
Tag,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Signals that a column title or tag name was empty, violating the schema's
|
||||||
|
/// CHECK constraint. Nothing was written.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class NameRequiredException : YKanBanException
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the entity whose name was empty.
|
||||||
|
/// </summary>
|
||||||
|
public NamedEntity Entity { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes the exception for an empty name.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="entity">The entity whose name was empty.</param>
|
||||||
|
/// <param name="innerException">The underlying constraint violation.</param>
|
||||||
|
public NameRequiredException(NamedEntity entity, Exception innerException)
|
||||||
|
: base($"The {entity.ToString().ToLowerInvariant()} name must not be empty.", innerException)
|
||||||
|
{
|
||||||
|
Entity = entity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Signals that a column title or tag name already exists (compared with ASCII
|
||||||
|
/// case folding), violating the schema's UNIQUE constraint. Nothing was written.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DuplicateNameException : YKanBanException
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the entity whose name collided.
|
||||||
|
/// </summary>
|
||||||
|
public NamedEntity Entity { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the rejected name.
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes the exception for a name collision.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="entity">The entity whose name collided.</param>
|
||||||
|
/// <param name="name">The rejected name.</param>
|
||||||
|
/// <param name="innerException">The underlying constraint violation.</param>
|
||||||
|
public DuplicateNameException(NamedEntity entity, string name, Exception innerException)
|
||||||
|
: base($"A {entity.ToString().ToLowerInvariant()} named '{name}' already exists.", innerException)
|
||||||
|
{
|
||||||
|
Entity = entity;
|
||||||
|
Name = name;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
using YKanBan.Models;
|
using YKanBan.Models;
|
||||||
|
using YKanBan.Search;
|
||||||
|
|
||||||
namespace YKanBan.Storage.Workspace;
|
namespace YKanBan.Storage.Workspace;
|
||||||
|
|
||||||
@@ -71,14 +72,15 @@ public sealed class WorkspaceRepository : IDisposable
|
|||||||
/// <param name="title">The non-empty, table-unique title.</param>
|
/// <param name="title">The non-empty, table-unique title.</param>
|
||||||
/// <param name="description">The optional description.</param>
|
/// <param name="description">The optional description.</param>
|
||||||
/// <returns>The created column.</returns>
|
/// <returns>The created column.</returns>
|
||||||
/// <exception cref="SqliteException">The title is empty or not unique.</exception>
|
/// <exception cref="NameRequiredException">The title is empty.</exception>
|
||||||
|
/// <exception cref="DuplicateNameException">The title is not unique.</exception>
|
||||||
public ColumnModel AddColumn(string title, string description)
|
public ColumnModel AddColumn(string title, string description)
|
||||||
{
|
{
|
||||||
long now = UnixTime.Now;
|
long now = UnixTime.Now;
|
||||||
using var transaction = _connection.BeginTransaction();
|
using var transaction = _connection.BeginTransaction();
|
||||||
long id = ExecuteInsert(transaction,
|
long id = MapNameConstraint(NamedEntity.Column, title, () => ExecuteInsert(transaction,
|
||||||
"INSERT INTO columns (title, description, created_at, updated_at) VALUES ($title, $description, $now, $now);",
|
"INSERT INTO columns (title, description, created_at, updated_at) VALUES ($title, $description, $now, $now);",
|
||||||
("$title", title), ("$description", description), ("$now", now));
|
("$title", title), ("$description", description), ("$now", now)));
|
||||||
transaction.Commit();
|
transaction.Commit();
|
||||||
|
|
||||||
return new ColumnModel(id, title, description, now, now);
|
return new ColumnModel(id, title, description, now, now);
|
||||||
@@ -93,7 +95,8 @@ public sealed class WorkspaceRepository : IDisposable
|
|||||||
/// <param name="description">The new description.</param>
|
/// <param name="description">The new description.</param>
|
||||||
/// <returns>The updated column, or the unchanged one for a no-op.</returns>
|
/// <returns>The updated column, or the unchanged one for a no-op.</returns>
|
||||||
/// <exception cref="InvalidOperationException">The column does not exist.</exception>
|
/// <exception cref="InvalidOperationException">The column does not exist.</exception>
|
||||||
/// <exception cref="SqliteException">The new title is empty or not unique.</exception>
|
/// <exception cref="NameRequiredException">The new title is empty.</exception>
|
||||||
|
/// <exception cref="DuplicateNameException">The new title is not unique.</exception>
|
||||||
public ColumnModel UpdateColumn(long columnId, string title, string description)
|
public ColumnModel UpdateColumn(long columnId, string title, string description)
|
||||||
{
|
{
|
||||||
ColumnModel existing = GetColumn(columnId)
|
ColumnModel existing = GetColumn(columnId)
|
||||||
@@ -112,7 +115,7 @@ public sealed class WorkspaceRepository : IDisposable
|
|||||||
command.Parameters.AddWithValue("$description", description);
|
command.Parameters.AddWithValue("$description", description);
|
||||||
command.Parameters.AddWithValue("$now", now);
|
command.Parameters.AddWithValue("$now", now);
|
||||||
command.Parameters.AddWithValue("$id", columnId);
|
command.Parameters.AddWithValue("$id", columnId);
|
||||||
command.ExecuteNonQuery();
|
MapNameConstraint(NamedEntity.Column, title, command.ExecuteNonQuery);
|
||||||
|
|
||||||
return new ColumnModel(columnId, title, description, existing.CreatedAt, now);
|
return new ColumnModel(columnId, title, description, existing.CreatedAt, now);
|
||||||
}
|
}
|
||||||
@@ -132,16 +135,34 @@ public sealed class WorkspaceRepository : IDisposable
|
|||||||
/// Returns all cards with their assigned tags loaded, ordered by id ascending.
|
/// Returns all cards with their assigned tags loaded, ordered by id ascending.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>All cards in id order.</returns>
|
/// <returns>All cards in id order.</returns>
|
||||||
public IReadOnlyList<CardModel> GetCards()
|
public IReadOnlyList<CardModel> GetCards() => GetCards(null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the cards matching a compiled search, with all of their assigned
|
||||||
|
/// tags loaded (the filter selects cards, it never trims their tag lists),
|
||||||
|
/// ordered by id ascending.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="filter">The compiled search, or <see langword="null"/> for every card.</param>
|
||||||
|
/// <returns>The matching cards in id order.</returns>
|
||||||
|
public IReadOnlyList<CardModel> GetCards(CompiledSearch? filter)
|
||||||
{
|
{
|
||||||
using var command = CreateCommand("""
|
string where = filter is null ? "" : $"WHERE {filter.Predicate}";
|
||||||
|
using var command = CreateCommand($"""
|
||||||
SELECT c.id, c.column_id, c.title, c.content, c.created_at, c.updated_at,
|
SELECT c.id, c.column_id, c.title, c.content, c.created_at, c.updated_at,
|
||||||
t.id, t.name, t.color, t.description
|
t.id, t.name, t.color, t.description
|
||||||
FROM cards c
|
FROM cards c
|
||||||
LEFT JOIN card_tags ct ON ct.card_id = c.id
|
LEFT JOIN card_tags ct ON ct.card_id = c.id
|
||||||
LEFT JOIN tags t ON t.id = ct.tag_id
|
LEFT JOIN tags t ON t.id = ct.tag_id
|
||||||
ORDER BY c.id ASC;
|
{where}
|
||||||
|
ORDER BY c.id ASC, t.id ASC;
|
||||||
""");
|
""");
|
||||||
|
if (filter is not null)
|
||||||
|
{
|
||||||
|
foreach ((string name, object value) in filter.Parameters)
|
||||||
|
{
|
||||||
|
command.Parameters.AddWithValue(name, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
using var reader = command.ExecuteReader();
|
using var reader = command.ExecuteReader();
|
||||||
|
|
||||||
// Single grouped pass: one output card per distinct card id, with its tags accumulated alongside.
|
// Single grouped pass: one output card per distinct card id, with its tags accumulated alongside.
|
||||||
@@ -186,7 +207,7 @@ public sealed class WorkspaceRepository : IDisposable
|
|||||||
LEFT JOIN card_tags ct ON ct.card_id = c.id
|
LEFT JOIN card_tags ct ON ct.card_id = c.id
|
||||||
LEFT JOIN tags t ON t.id = ct.tag_id
|
LEFT JOIN tags t ON t.id = ct.tag_id
|
||||||
WHERE c.id = $id
|
WHERE c.id = $id
|
||||||
ORDER BY c.id ASC;
|
ORDER BY t.id ASC;
|
||||||
""");
|
""");
|
||||||
command.Parameters.AddWithValue("$id", cardId);
|
command.Parameters.AddWithValue("$id", cardId);
|
||||||
using var reader = command.ExecuteReader();
|
using var reader = command.ExecuteReader();
|
||||||
@@ -302,6 +323,18 @@ public sealed class WorkspaceRepository : IDisposable
|
|||||||
return command.ExecuteNonQuery() > 0;
|
return command.ExecuteNonQuery() > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Counts the cards of a column, regardless of any search filter.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="columnId">The column id.</param>
|
||||||
|
/// <returns>The number of cards in the column.</returns>
|
||||||
|
public long CountCards(long columnId)
|
||||||
|
{
|
||||||
|
using var command = CreateCommand("SELECT COUNT(*) FROM cards WHERE column_id = $id;");
|
||||||
|
command.Parameters.AddWithValue("$id", columnId);
|
||||||
|
return (long)command.ExecuteScalar()!;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Deletes a card; the DDL cascade removes its tag links.
|
/// Deletes a card; the DDL cascade removes its tag links.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -361,13 +394,14 @@ public sealed class WorkspaceRepository : IDisposable
|
|||||||
/// <param name="color">The tag color.</param>
|
/// <param name="color">The tag color.</param>
|
||||||
/// <param name="description">The optional description.</param>
|
/// <param name="description">The optional description.</param>
|
||||||
/// <returns>The created tag.</returns>
|
/// <returns>The created tag.</returns>
|
||||||
/// <exception cref="SqliteException">The name is empty or not unique.</exception>
|
/// <exception cref="NameRequiredException">The name is empty.</exception>
|
||||||
|
/// <exception cref="DuplicateNameException">The name is not unique.</exception>
|
||||||
public TagModel AddTag(string name, RgbColor color, string description)
|
public TagModel AddTag(string name, RgbColor color, string description)
|
||||||
{
|
{
|
||||||
using var transaction = _connection.BeginTransaction();
|
using var transaction = _connection.BeginTransaction();
|
||||||
long id = ExecuteInsert(transaction,
|
long id = MapNameConstraint(NamedEntity.Tag, name, () => ExecuteInsert(transaction,
|
||||||
"INSERT INTO tags (name, color, description) VALUES ($name, $color, $description);",
|
"INSERT INTO tags (name, color, description) VALUES ($name, $color, $description);",
|
||||||
("$name", name), ("$color", color.ToString()), ("$description", description));
|
("$name", name), ("$color", color.ToString()), ("$description", description)));
|
||||||
transaction.Commit();
|
transaction.Commit();
|
||||||
|
|
||||||
return new TagModel(id, name, color, description);
|
return new TagModel(id, name, color, description);
|
||||||
@@ -382,7 +416,8 @@ public sealed class WorkspaceRepository : IDisposable
|
|||||||
/// <param name="description">The new description.</param>
|
/// <param name="description">The new description.</param>
|
||||||
/// <returns>The updated tag, or the unchanged one for a no-op.</returns>
|
/// <returns>The updated tag, or the unchanged one for a no-op.</returns>
|
||||||
/// <exception cref="InvalidOperationException">The tag does not exist.</exception>
|
/// <exception cref="InvalidOperationException">The tag does not exist.</exception>
|
||||||
/// <exception cref="SqliteException">The new name is empty or not unique.</exception>
|
/// <exception cref="NameRequiredException">The new name is empty.</exception>
|
||||||
|
/// <exception cref="DuplicateNameException">The new name is not unique.</exception>
|
||||||
public TagModel UpdateTag(long tagId, string name, RgbColor color, string description)
|
public TagModel UpdateTag(long tagId, string name, RgbColor color, string description)
|
||||||
{
|
{
|
||||||
TagModel existing = GetTag(tagId)
|
TagModel existing = GetTag(tagId)
|
||||||
@@ -400,7 +435,7 @@ public sealed class WorkspaceRepository : IDisposable
|
|||||||
command.Parameters.AddWithValue("$color", color.ToString());
|
command.Parameters.AddWithValue("$color", color.ToString());
|
||||||
command.Parameters.AddWithValue("$description", description);
|
command.Parameters.AddWithValue("$description", description);
|
||||||
command.Parameters.AddWithValue("$id", tagId);
|
command.Parameters.AddWithValue("$id", tagId);
|
||||||
command.ExecuteNonQuery();
|
MapNameConstraint(NamedEntity.Tag, name, command.ExecuteNonQuery);
|
||||||
|
|
||||||
return new TagModel(tagId, name, color, description);
|
return new TagModel(tagId, name, color, description);
|
||||||
}
|
}
|
||||||
@@ -416,6 +451,40 @@ public sealed class WorkspaceRepository : IDisposable
|
|||||||
|
|
||||||
#region Helpers
|
#region Helpers
|
||||||
|
|
||||||
|
/// <summary>SQLite extended result code of a failed CHECK constraint.</summary>
|
||||||
|
private const int SqliteConstraintCheck = 275;
|
||||||
|
|
||||||
|
/// <summary>SQLite extended result code of a failed UNIQUE constraint.</summary>
|
||||||
|
private const int SqliteConstraintUnique = 2067;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs a write against columns.title or tags.name and translates the
|
||||||
|
/// schema's non-empty (CHECK) and uniqueness (UNIQUE) violations into typed
|
||||||
|
/// exceptions that the UI maps to localized messages.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="T">The write's result type.</typeparam>
|
||||||
|
/// <param name="entity">The entity being written.</param>
|
||||||
|
/// <param name="name">The name or title being written.</param>
|
||||||
|
/// <param name="write">The write to run.</param>
|
||||||
|
/// <returns>The write's result.</returns>
|
||||||
|
/// <exception cref="NameRequiredException">The name is empty.</exception>
|
||||||
|
/// <exception cref="DuplicateNameException">The name is not unique.</exception>
|
||||||
|
private static T MapNameConstraint<T>(NamedEntity entity, string name, Func<T> write)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return write();
|
||||||
|
}
|
||||||
|
catch (SqliteException exception) when (exception.SqliteExtendedErrorCode == SqliteConstraintCheck)
|
||||||
|
{
|
||||||
|
throw new NameRequiredException(entity, exception);
|
||||||
|
}
|
||||||
|
catch (SqliteException exception) when (exception.SqliteExtendedErrorCode == SqliteConstraintUnique)
|
||||||
|
{
|
||||||
|
throw new DuplicateNameException(entity, name, exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the tag with the given id.
|
/// Returns the tag with the given id.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using YKanBan.Models;
|
||||||
|
using YKanBan.Search;
|
||||||
|
using YKanBan.Storage.AppData;
|
||||||
|
using YKanBan.Storage.Workspace;
|
||||||
|
using YKanBan.ViewModels.Dialogs;
|
||||||
|
|
||||||
|
namespace YKanBan.ViewModels.Board;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The board tab: the toolbar (new column, column width, card sort) and the
|
||||||
|
/// columns laid out left to right, each listing its cards top to bottom.
|
||||||
|
///
|
||||||
|
/// Every write is followed by <see cref="Refresh"/>, which re-queries with the
|
||||||
|
/// most recently successful search expression (<see cref="ActiveFilter"/>), not
|
||||||
|
/// with whatever the search box currently holds: cards that no longer match
|
||||||
|
/// disappear, new cards that do not match are not shown, and empty columns stay.
|
||||||
|
/// </summary>
|
||||||
|
public sealed partial class BoardViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
private readonly WorkspaceRepository _repository;
|
||||||
|
private readonly AppConfig _config;
|
||||||
|
private readonly IDialogService _dialogs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes the board and loads it.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="repository">The workspace repository.</param>
|
||||||
|
/// <param name="config">The live application configuration (sort, column width, confirmations).</param>
|
||||||
|
/// <param name="dialogs">The modal dialog service.</param>
|
||||||
|
public BoardViewModel(WorkspaceRepository repository, AppConfig config, IDialogService dialogs)
|
||||||
|
{
|
||||||
|
_repository = repository;
|
||||||
|
_config = config;
|
||||||
|
_dialogs = dialogs;
|
||||||
|
|
||||||
|
SortOptions =
|
||||||
|
[
|
||||||
|
new OptionItem(CardSortOption.Id, Resources.Sort_Id),
|
||||||
|
new OptionItem(CardSortOption.UpdatedAt, Resources.Sort_UpdatedAt),
|
||||||
|
new OptionItem(CardSortOption.Title, Resources.Sort_Title),
|
||||||
|
];
|
||||||
|
selectedSort = SortOptions.FirstOrDefault(option => (CardSortOption)option.Value == config.Sort.Card)
|
||||||
|
?? SortOptions[0];
|
||||||
|
columnWidth = ColumnWidths.Resolve(config.ColumnWidth);
|
||||||
|
|
||||||
|
Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the columns in id order.
|
||||||
|
/// </summary>
|
||||||
|
public ObservableCollection<ColumnViewModel> Columns { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the card sort options shown in the combobox.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<OptionItem> SortOptions { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the column models in id order, as of the last refresh.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<ColumnModel> ColumnModels { get; private set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the most recently successful search; <see langword="null"/> shows every card.
|
||||||
|
/// </summary>
|
||||||
|
public CompiledSearch? ActiveFilter { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the selected card sort option; changing it is persisted and re-sorts the board.
|
||||||
|
/// </summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private OptionItem selectedSort;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the effective column width in pixels.
|
||||||
|
/// </summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private int columnWidth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reloads the columns and the cards matching <see cref="ActiveFilter"/>.
|
||||||
|
/// </summary>
|
||||||
|
public void Refresh()
|
||||||
|
{
|
||||||
|
IReadOnlyList<ColumnModel> columns = _repository.GetColumns();
|
||||||
|
ILookup<long, CardModel> cardsByColumn = _repository.GetCards(ActiveFilter).ToLookup(card => card.ColumnId);
|
||||||
|
var sort = (CardSortOption)SelectedSort.Value;
|
||||||
|
|
||||||
|
ColumnModels = columns;
|
||||||
|
Columns.Clear();
|
||||||
|
foreach (ColumnModel column in columns)
|
||||||
|
{
|
||||||
|
Columns.Add(new ColumnViewModel(this, column, CardOrdering.Sort(cardsByColumn[column.Id], sort)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens the new-column dialog.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A task completing when the dialog closes.</returns>
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task AddColumnAsync()
|
||||||
|
{
|
||||||
|
var dialog = new ColumnEditorDialogViewModel(null, (title, description) => _repository.AddColumn(title, description));
|
||||||
|
if (await _dialogs.ShowAsync(dialog))
|
||||||
|
{
|
||||||
|
Refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens the column width dialog and applies the choice.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A task completing when the dialog closes.</returns>
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task AdjustColumnWidthAsync()
|
||||||
|
{
|
||||||
|
if (await _dialogs.ShowAsync(new ColumnWidthDialogViewModel(_config.ColumnWidth)))
|
||||||
|
{
|
||||||
|
ColumnWidth = ColumnWidths.Resolve(_config.ColumnWidth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens the edit-column dialog.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="column">The column to edit.</param>
|
||||||
|
/// <returns>A task completing when the dialog closes.</returns>
|
||||||
|
internal async Task EditColumnAsync(ColumnModel column)
|
||||||
|
{
|
||||||
|
var dialog = new ColumnEditorDialogViewModel(
|
||||||
|
column, (title, description) => _repository.UpdateColumn(column.Id, title, description));
|
||||||
|
if (await _dialogs.ShowAsync(dialog))
|
||||||
|
{
|
||||||
|
Refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes a column with its cards, asking first when that confirmation is enabled.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="column">The column to delete.</param>
|
||||||
|
/// <returns>A task completing when the column is deleted or the deletion was declined.</returns>
|
||||||
|
internal async Task DeleteColumnAsync(ColumnModel column)
|
||||||
|
{
|
||||||
|
await RunWriteAsync(async () =>
|
||||||
|
{
|
||||||
|
if (_config.Confirmations.DeleteColumn)
|
||||||
|
{
|
||||||
|
long count = _repository.CountCards(column.Id);
|
||||||
|
string message = string.Format(Resources.Culture, Resources.Confirm_DeleteColumn, column.Title, count);
|
||||||
|
if (!await _dialogs.ConfirmAsync(message, Resources.Common_Delete))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_repository.DeleteColumn(column.Id);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens the card editor for a new card in a column.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="columnId">The column receiving the card.</param>
|
||||||
|
/// <returns>A task completing when the editor closes.</returns>
|
||||||
|
internal async Task AddCardAsync(long columnId)
|
||||||
|
{
|
||||||
|
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.
|
||||||
|
Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens the card editor for an existing card, reloaded from the database.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cardId">The card to edit.</param>
|
||||||
|
/// <returns>A task completing when the editor closes.</returns>
|
||||||
|
internal async Task EditCardAsync(long cardId)
|
||||||
|
{
|
||||||
|
CardModel? card = _repository.GetCard(cardId);
|
||||||
|
if (card is null)
|
||||||
|
{
|
||||||
|
Refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var editor = CardEditorDialogViewModel.ForExistingCard(
|
||||||
|
_repository, _dialogs, card, _config.Confirmations.DiscardEdit);
|
||||||
|
await _dialogs.ShowAsync(editor);
|
||||||
|
Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes a card, asking first when that confirmation is enabled.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="card">The card to delete.</param>
|
||||||
|
/// <returns>A task completing when the card is deleted or the deletion was declined.</returns>
|
||||||
|
internal async Task DeleteCardAsync(CardModel card)
|
||||||
|
{
|
||||||
|
await RunWriteAsync(async () =>
|
||||||
|
{
|
||||||
|
if (_config.Confirmations.DeleteCard)
|
||||||
|
{
|
||||||
|
string message = string.Format(Resources.Culture, Resources.Confirm_DeleteCard, CardViewModel.FormatHeader(card));
|
||||||
|
if (!await _dialogs.ConfirmAsync(message, Resources.Common_Delete))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_repository.DeleteCard(card.Id);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Moves a card to another column.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cardId">The card to move.</param>
|
||||||
|
/// <param name="columnId">The target column.</param>
|
||||||
|
/// <returns>A task completing when the card is moved.</returns>
|
||||||
|
internal Task MoveCardAsync(long cardId, long columnId) =>
|
||||||
|
RunWriteAsync(() =>
|
||||||
|
{
|
||||||
|
_repository.MoveCard(cardId, columnId);
|
||||||
|
return Task.FromResult(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs an immediate write and refreshes after it; an unexpected failure is
|
||||||
|
/// reported in a message dialog and the board is refreshed to the stored state.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="write">The write; returns <see langword="false"/> when the user declined it.</param>
|
||||||
|
/// <returns>A task completing after the refresh.</returns>
|
||||||
|
private async Task RunWriteAsync(Func<Task<bool>> write)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!await write())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
Debug.WriteLine(exception);
|
||||||
|
await _dialogs.ShowMessageAsync(
|
||||||
|
Resources.Error_Operation_Title, Resources.Error_Operation_Message, exception.Message);
|
||||||
|
}
|
||||||
|
Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persists the sort option and re-sorts the board.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="value">The new sort option.</param>
|
||||||
|
partial void OnSelectedSortChanged(OptionItem value)
|
||||||
|
{
|
||||||
|
_config.Sort.Card = (CardSortOption)value.Value;
|
||||||
|
Refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using YKanBan.Models;
|
||||||
|
|
||||||
|
namespace YKanBan.ViewModels.Board;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One card in a column list: "#id" in bold on the same line as the title, the
|
||||||
|
/// content cut to a fixed length, and the tag badges below. No timestamps.
|
||||||
|
/// Clicking the card opens the editor; its menu deletes or moves it.
|
||||||
|
/// </summary>
|
||||||
|
public sealed partial class CardViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The number of text elements of content shown on the card.
|
||||||
|
/// </summary>
|
||||||
|
public const int ExcerptLength = 200;
|
||||||
|
|
||||||
|
private readonly BoardViewModel _board;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes the card.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="board">The owning board.</param>
|
||||||
|
/// <param name="card">The card shown.</param>
|
||||||
|
public CardViewModel(BoardViewModel board, CardModel card)
|
||||||
|
{
|
||||||
|
_board = board;
|
||||||
|
Card = card;
|
||||||
|
IdText = $"#{card.Id.ToString(CultureInfo.InvariantCulture)}";
|
||||||
|
Excerpt = MakeExcerpt(card.Content);
|
||||||
|
Tags = card.Tags.Select(tag => new TagBadgeViewModel(tag)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the card shown.
|
||||||
|
/// </summary>
|
||||||
|
public CardModel Card { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the "#id" text.
|
||||||
|
/// </summary>
|
||||||
|
public string IdText { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the title; empty when the card has none.
|
||||||
|
/// </summary>
|
||||||
|
public string Title => Card.Title;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the title as it follows "#id" on the header line: a space and the
|
||||||
|
/// title, or nothing when the card has no title.
|
||||||
|
/// </summary>
|
||||||
|
public string TitleSuffix => Card.Title.Length == 0 ? "" : " " + Card.Title;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the truncated content.
|
||||||
|
/// </summary>
|
||||||
|
public string Excerpt { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets whether there is content to show.
|
||||||
|
/// </summary>
|
||||||
|
public bool HasExcerpt => Excerpt.Length > 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the tag badges.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<TagBadgeViewModel> Tags { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets whether the card has tags.
|
||||||
|
/// </summary>
|
||||||
|
public bool HasTags => Tags.Count > 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the "move to column" targets; built when the menu asks for them.
|
||||||
|
/// The card's current column is listed but disabled.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<MoveTargetViewModel> MoveTargets =>
|
||||||
|
_board.ColumnModels
|
||||||
|
.Select(column => new MoveTargetViewModel(column.Title, column.Id != Card.ColumnId, MoveToCommand, column.Id))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Formats the "#id title" header text of a card ("#id" alone without a title).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="card">The card.</param>
|
||||||
|
/// <returns>The header text.</returns>
|
||||||
|
public static string FormatHeader(CardModel card) =>
|
||||||
|
card.Title.Length == 0
|
||||||
|
? $"#{card.Id.ToString(CultureInfo.InvariantCulture)}"
|
||||||
|
: $"#{card.Id.ToString(CultureInfo.InvariantCulture)} {card.Title}";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cuts content to <see cref="ExcerptLength"/> text elements, appending an
|
||||||
|
/// ellipsis when something was cut. Surrogate pairs and combining sequences
|
||||||
|
/// are never split.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="content">The card content.</param>
|
||||||
|
/// <returns>The excerpt.</returns>
|
||||||
|
public static string MakeExcerpt(string content)
|
||||||
|
{
|
||||||
|
string trimmed = content.Trim();
|
||||||
|
var info = new StringInfo(trimmed);
|
||||||
|
return info.LengthInTextElements <= ExcerptLength
|
||||||
|
? trimmed
|
||||||
|
: info.SubstringByTextElements(0, ExcerptLength).TrimEnd() + "…";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens the card editor.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A task completing when the editor closes.</returns>
|
||||||
|
[RelayCommand]
|
||||||
|
private Task EditAsync() => _board.EditCardAsync(Card.Id);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes the card.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A task completing when the card is deleted or the deletion was declined.</returns>
|
||||||
|
[RelayCommand]
|
||||||
|
private Task DeleteAsync() => _board.DeleteCardAsync(Card);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Moves the card to another column.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="columnId">The target column.</param>
|
||||||
|
/// <returns>A task completing when the card is moved.</returns>
|
||||||
|
[RelayCommand]
|
||||||
|
private Task MoveToAsync(long columnId) => _board.MoveCardAsync(Card.Id, columnId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An entry of a card's "move to column" submenu.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Title">The column title.</param>
|
||||||
|
/// <param name="IsEnabled">Whether the entry can be picked (false for the card's own column).</param>
|
||||||
|
/// <param name="Command">The move command.</param>
|
||||||
|
/// <param name="ColumnId">The target column, passed to <paramref name="Command"/>.</param>
|
||||||
|
public sealed record MoveTargetViewModel(string Title, bool IsEnabled, IAsyncRelayCommand<long> Command, long ColumnId);
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using YKanBan.Models;
|
||||||
|
|
||||||
|
namespace YKanBan.ViewModels.Board;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One board column: its header (title, card count, description, the new-card
|
||||||
|
/// button and the edit/delete menu) and its virtualized card list.
|
||||||
|
/// </summary>
|
||||||
|
public sealed partial class ColumnViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
private readonly BoardViewModel _board;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes the column.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="board">The owning board.</param>
|
||||||
|
/// <param name="column">The column shown.</param>
|
||||||
|
/// <param name="cards">The column's visible cards, in display order.</param>
|
||||||
|
public ColumnViewModel(BoardViewModel board, ColumnModel column, IReadOnlyList<CardModel> cards)
|
||||||
|
{
|
||||||
|
_board = board;
|
||||||
|
Column = column;
|
||||||
|
Cards = cards.Select(card => new CardViewModel(board, card)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the column shown.
|
||||||
|
/// </summary>
|
||||||
|
public ColumnModel Column { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the column title.
|
||||||
|
/// </summary>
|
||||||
|
public string Title => Column.Title;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the column description.
|
||||||
|
/// </summary>
|
||||||
|
public string Description => Column.Description;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets whether the column has a description to show.
|
||||||
|
/// </summary>
|
||||||
|
public bool HasDescription => Column.Description.Length > 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the number of visible cards.
|
||||||
|
/// </summary>
|
||||||
|
public int CardCount => Cards.Count;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the visible cards in display order.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<CardViewModel> Cards { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens the card editor for a new card in this column.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A task completing when the editor closes.</returns>
|
||||||
|
[RelayCommand]
|
||||||
|
private Task AddCardAsync() => _board.AddCardAsync(Column.Id);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens the edit-column dialog.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A task completing when the dialog closes.</returns>
|
||||||
|
[RelayCommand]
|
||||||
|
private Task EditAsync() => _board.EditColumnAsync(Column);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes the column with its cards.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A task completing when the column is deleted or the deletion was declined.</returns>
|
||||||
|
[RelayCommand]
|
||||||
|
private Task DeleteAsync() => _board.DeleteColumnAsync(Column);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using Avalonia.Media;
|
||||||
|
using Avalonia.Media.Immutable;
|
||||||
|
using YKanBan.Models;
|
||||||
|
|
||||||
|
namespace YKanBan.ViewModels.Board;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A colored tag badge (color + name). Tag colors are user data: they are shown
|
||||||
|
/// as the literal #RRGGBB value in both themes, with a black or white text color
|
||||||
|
/// chosen for contrast.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TagBadgeViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes the badge.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="tag">The tag shown.</param>
|
||||||
|
public TagBadgeViewModel(TagModel tag)
|
||||||
|
{
|
||||||
|
Tag = tag;
|
||||||
|
Background = new ImmutableSolidColorBrush(Color.FromRgb(tag.Color.R, tag.Color.G, tag.Color.B));
|
||||||
|
Foreground = IsLight(tag.Color) ? Brushes.Black : Brushes.White;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the tag shown.
|
||||||
|
/// </summary>
|
||||||
|
public TagModel Tag { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the tag name.
|
||||||
|
/// </summary>
|
||||||
|
public string Name => Tag.Name;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the tag description.
|
||||||
|
/// </summary>
|
||||||
|
public string Description => Tag.Description;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the badge fill: the tag's own color.
|
||||||
|
/// </summary>
|
||||||
|
public IBrush Background { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the text color contrasting with <see cref="Background"/>.
|
||||||
|
/// </summary>
|
||||||
|
public IBrush Foreground { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decides whether dark text reads better on a color, by its relative luminance.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="color">The background color.</param>
|
||||||
|
/// <returns><see langword="true"/> for light colors.</returns>
|
||||||
|
public static bool IsLight(RgbColor color) =>
|
||||||
|
(0.299 * color.R) + (0.587 * color.G) + (0.114 * color.B) > 150;
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Globalization;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using YKanBan.Models;
|
||||||
|
using YKanBan.Storage.Workspace;
|
||||||
|
using YKanBan.ViewModels.Board;
|
||||||
|
|
||||||
|
namespace YKanBan.ViewModels.Dialogs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The card editor — one unit of work. Title, content and the tag assignment
|
||||||
|
/// are edited in memory and written together in a single transaction on
|
||||||
|
/// confirmation; cancelling writes nothing about the card or its assignment.
|
||||||
|
/// Confirming an existing card without effective changes writes nothing and
|
||||||
|
/// keeps its timestamps. The only immediate write is the creation of a new tag
|
||||||
|
/// in the nested tag picker (see <see cref="TagPickerDialogViewModel"/>).
|
||||||
|
/// </summary>
|
||||||
|
public sealed partial class CardEditorDialogViewModel : DialogViewModelBase
|
||||||
|
{
|
||||||
|
private readonly WorkspaceRepository _repository;
|
||||||
|
private readonly IDialogService _dialogs;
|
||||||
|
private readonly CardModel? _existing;
|
||||||
|
private readonly long _columnId;
|
||||||
|
private readonly bool _confirmDiscard;
|
||||||
|
private readonly string _initialTitle;
|
||||||
|
private readonly string _initialContent;
|
||||||
|
private readonly HashSet<long> _initialTagIds;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes the editor for a new card in a column.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="repository">The workspace repository.</param>
|
||||||
|
/// <param name="dialogs">The dialog service, for the nested tag picker and the discard confirmation.</param>
|
||||||
|
/// <param name="columnId">The column receiving the new card.</param>
|
||||||
|
/// <param name="confirmDiscard">Whether discarding changes asks for confirmation.</param>
|
||||||
|
/// <returns>The editor.</returns>
|
||||||
|
public static CardEditorDialogViewModel ForNewCard(
|
||||||
|
WorkspaceRepository repository, IDialogService dialogs, long columnId, bool confirmDiscard) =>
|
||||||
|
new(repository, dialogs, null, columnId, confirmDiscard);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes the editor for an existing card.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="repository">The workspace repository.</param>
|
||||||
|
/// <param name="dialogs">The dialog service, for the nested tag picker and the discard confirmation.</param>
|
||||||
|
/// <param name="card">The card to edit.</param>
|
||||||
|
/// <param name="confirmDiscard">Whether discarding changes asks for confirmation.</param>
|
||||||
|
/// <returns>The editor.</returns>
|
||||||
|
public static CardEditorDialogViewModel ForExistingCard(
|
||||||
|
WorkspaceRepository repository, IDialogService dialogs, CardModel card, bool confirmDiscard) =>
|
||||||
|
new(repository, dialogs, card, card.ColumnId, confirmDiscard);
|
||||||
|
|
||||||
|
private CardEditorDialogViewModel(
|
||||||
|
WorkspaceRepository repository, IDialogService dialogs, CardModel? existing, long columnId, bool confirmDiscard)
|
||||||
|
{
|
||||||
|
_repository = repository;
|
||||||
|
_dialogs = dialogs;
|
||||||
|
_existing = existing;
|
||||||
|
_columnId = columnId;
|
||||||
|
_confirmDiscard = confirmDiscard;
|
||||||
|
_initialTitle = existing?.Title ?? "";
|
||||||
|
_initialContent = existing?.Content ?? "";
|
||||||
|
_initialTagIds = existing?.Tags.Select(tag => tag.Id).ToHashSet() ?? [];
|
||||||
|
|
||||||
|
cardTitle = _initialTitle;
|
||||||
|
content = _initialContent;
|
||||||
|
Tags = new ObservableCollection<TagBadgeViewModel>(
|
||||||
|
existing?.Tags.Select(tag => new TagBadgeViewModel(tag)) ?? []);
|
||||||
|
|
||||||
|
if (existing is null)
|
||||||
|
{
|
||||||
|
Title = Resources.CardEditor_TitleNew;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
CultureInfo? culture = Resources.Culture;
|
||||||
|
Title = Resources.CardEditor_TitleEdit;
|
||||||
|
IdText = $"#{existing.Id.ToString(CultureInfo.InvariantCulture)}";
|
||||||
|
CreatedText = string.Format(culture, Resources.CardEditor_Created, FormatTime(existing.CreatedAt));
|
||||||
|
ModifiedText = string.Format(culture, Resources.CardEditor_Modified, FormatTime(existing.UpdatedAt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the localized window title.
|
||||||
|
/// </summary>
|
||||||
|
public string Title { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets whether the card already exists (and thus has an id and timestamps to show).
|
||||||
|
/// </summary>
|
||||||
|
public bool IsExisting => _existing is not null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the read-only "#id" text of an existing card.
|
||||||
|
/// </summary>
|
||||||
|
public string? IdText { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the read-only creation time line of an existing card.
|
||||||
|
/// </summary>
|
||||||
|
public string? CreatedText { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the read-only modification time line of an existing card.
|
||||||
|
/// </summary>
|
||||||
|
public string? ModifiedText { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the tags assigned in this edit session, in assignment order.
|
||||||
|
/// </summary>
|
||||||
|
public ObservableCollection<TagBadgeViewModel> Tags { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the card written on confirmation; <see langword="null"/> until then.
|
||||||
|
/// </summary>
|
||||||
|
public CardModel? SavedCard { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the card title; the empty string means "no title".
|
||||||
|
/// </summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private string cardTitle;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the multiline card content.
|
||||||
|
/// </summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private string content;
|
||||||
|
|
||||||
|
/// <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>
|
||||||
|
/// Gets whether the in-memory state differs from the state the editor opened with.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsDirty =>
|
||||||
|
CardTitle != _initialTitle
|
||||||
|
|| Content != _initialContent
|
||||||
|
|| !_initialTagIds.SetEquals(Tags.Select(tag => tag.Tag.Id));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens the nested tag picker and assigns the picked (or newly created) tag.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A task completing when the picker closes.</returns>
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task AddTagAsync()
|
||||||
|
{
|
||||||
|
var picker = new TagPickerDialogViewModel(_repository, Tags.Select(tag => tag.Tag.Id).ToHashSet());
|
||||||
|
if (await _dialogs.ShowAsync(picker) && picker.PickedTag is { } picked)
|
||||||
|
{
|
||||||
|
Tags.Add(new TagBadgeViewModel(picked));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes a tag from the in-memory assignment.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="tag">The tag to remove.</param>
|
||||||
|
[RelayCommand]
|
||||||
|
private void RemoveTag(TagBadgeViewModel tag) => Tags.Remove(tag);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes the card in one transaction and closes; an unchanged existing card
|
||||||
|
/// is not written at all.
|
||||||
|
/// </summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private void Confirm()
|
||||||
|
{
|
||||||
|
if (_existing is not null && !IsDirty)
|
||||||
|
{
|
||||||
|
SavedCard = _existing;
|
||||||
|
Close(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
long[] tagIds = Tags.Select(tag => tag.Tag.Id).ToArray();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
SavedCard = _existing is null
|
||||||
|
? _repository.AddCard(_columnId, CardTitle, Content, tagIds)
|
||||||
|
: _repository.UpdateCard(_existing.Id, CardTitle, Content, tagIds);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
ErrorMessage = WriteErrorMessages.Describe(exception);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Close(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Closes without writing the card, asking first when there are unsaved
|
||||||
|
/// changes and the discard confirmation is enabled.
|
||||||
|
/// </summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private Task CancelAsync() => RequestCancelAsync();
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public override async Task RequestCancelAsync()
|
||||||
|
{
|
||||||
|
if (_confirmDiscard && IsDirty
|
||||||
|
&& !await _dialogs.ConfirmAsync(Resources.Confirm_DiscardEdit, Resources.Common_Discard))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Close(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Formats a Unix-seconds timestamp in local time with the language's pattern.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="unixSeconds">The timestamp.</param>
|
||||||
|
/// <returns>The formatted time.</returns>
|
||||||
|
private static string FormatTime(long unixSeconds) =>
|
||||||
|
DateTimeOffset.FromUnixTimeSeconds(unixSeconds).ToLocalTime()
|
||||||
|
.ToString(Resources.Common_TimePattern, Resources.Culture);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using YKanBan.Models;
|
||||||
|
|
||||||
|
namespace YKanBan.ViewModels.Dialogs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The new-column / edit-column dialog: a required title and an optional
|
||||||
|
/// description. Confirming writes through the supplied save callback; a
|
||||||
|
/// schema-constraint violation (empty or duplicate title) is shown inside the
|
||||||
|
/// dialog, which stays open with the user's input intact.
|
||||||
|
/// </summary>
|
||||||
|
public sealed partial class ColumnEditorDialogViewModel : DialogViewModelBase
|
||||||
|
{
|
||||||
|
private readonly Action<string, string> _save;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes the dialog.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="existing">The column being edited, or <see langword="null"/> for a new column.</param>
|
||||||
|
/// <param name="save">Writes the title and description; throws the repository's typed exceptions.</param>
|
||||||
|
public ColumnEditorDialogViewModel(ColumnModel? existing, Action<string, string> save)
|
||||||
|
{
|
||||||
|
_save = save;
|
||||||
|
Title = existing is null ? Resources.ColumnEditor_TitleNew : Resources.ColumnEditor_TitleEdit;
|
||||||
|
columnTitle = existing?.Title ?? "";
|
||||||
|
description = existing?.Description ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the localized window title.
|
||||||
|
/// </summary>
|
||||||
|
public string Title { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the column title being edited (not trimmed).
|
||||||
|
/// </summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private string columnTitle;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the column description being edited.
|
||||||
|
/// </summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private string description;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the localized constraint 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>
|
||||||
|
/// Writes the column; closes on success, otherwise shows the constraint error.
|
||||||
|
/// </summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private void Confirm()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_save(ColumnTitle, Description);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
ErrorMessage = WriteErrorMessages.Describe(exception);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Close(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Closes without writing.
|
||||||
|
/// </summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private Task CancelAsync() => RequestCancelAsync();
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using YKanBan.Storage.AppData;
|
||||||
|
|
||||||
|
namespace YKanBan.ViewModels.Dialogs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The column width dialog: one of the narrow / standard / wide presets or a
|
||||||
|
/// manual pixel width (160–720). The choice is global and is committed to the
|
||||||
|
/// live configuration on confirmation only.
|
||||||
|
/// </summary>
|
||||||
|
public sealed partial class ColumnWidthDialogViewModel : DialogViewModelBase
|
||||||
|
{
|
||||||
|
private readonly ColumnWidthSettings _settings;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes the dialog from the live settings.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="settings">The live column width settings.</param>
|
||||||
|
public ColumnWidthDialogViewModel(ColumnWidthSettings settings)
|
||||||
|
{
|
||||||
|
_settings = settings;
|
||||||
|
CultureInfo? culture = Resources.Culture;
|
||||||
|
NarrowLabel = string.Format(culture, Resources.ColumnWidth_Narrow, ColumnWidths.Narrow);
|
||||||
|
StandardLabel = string.Format(culture, Resources.ColumnWidth_Standard, ColumnWidths.Standard);
|
||||||
|
WideLabel = string.Format(culture, Resources.ColumnWidth_Wide, ColumnWidths.Wide);
|
||||||
|
RangeLabel = string.Format(culture, Resources.ColumnWidth_Range, AppConfig.MinColumnPixels, AppConfig.MaxColumnPixels);
|
||||||
|
|
||||||
|
isNarrow = settings.Preset == ColumnWidthPreset.Narrow;
|
||||||
|
isStandard = settings.Preset == ColumnWidthPreset.Standard;
|
||||||
|
isWide = settings.Preset == ColumnWidthPreset.Wide;
|
||||||
|
isCustom = settings.Preset == ColumnWidthPreset.Custom;
|
||||||
|
customPixels = settings.CustomPixels;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Gets the localized narrow preset label.</summary>
|
||||||
|
public string NarrowLabel { get; }
|
||||||
|
|
||||||
|
/// <summary>Gets the localized standard preset label.</summary>
|
||||||
|
public string StandardLabel { get; }
|
||||||
|
|
||||||
|
/// <summary>Gets the localized wide preset label.</summary>
|
||||||
|
public string WideLabel { get; }
|
||||||
|
|
||||||
|
/// <summary>Gets the localized accepted pixel range.</summary>
|
||||||
|
public string RangeLabel { get; }
|
||||||
|
|
||||||
|
/// <summary>Gets the smallest accepted custom width.</summary>
|
||||||
|
public int MinPixels => AppConfig.MinColumnPixels;
|
||||||
|
|
||||||
|
/// <summary>Gets the largest accepted custom width.</summary>
|
||||||
|
public int MaxPixels => AppConfig.MaxColumnPixels;
|
||||||
|
|
||||||
|
/// <summary>Gets or sets whether the narrow preset is selected.</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool isNarrow;
|
||||||
|
|
||||||
|
/// <summary>Gets or sets whether the standard preset is selected.</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool isStandard;
|
||||||
|
|
||||||
|
/// <summary>Gets or sets whether the wide preset is selected.</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool isWide;
|
||||||
|
|
||||||
|
/// <summary>Gets or sets whether the manual width is selected.</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool isCustom;
|
||||||
|
|
||||||
|
/// <summary>Gets or sets the manual width in pixels; <see langword="null"/> while the box is empty.</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private decimal? customPixels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Commits the selection to the live settings and closes.
|
||||||
|
/// </summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private void Confirm()
|
||||||
|
{
|
||||||
|
if (IsCustom)
|
||||||
|
{
|
||||||
|
int pixels = (int)Math.Clamp(CustomPixels ?? _settings.CustomPixels, AppConfig.MinColumnPixels, AppConfig.MaxColumnPixels);
|
||||||
|
_settings.Preset = ColumnWidthPreset.Custom;
|
||||||
|
_settings.CustomPixels = pixels;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_settings.Preset = IsNarrow ? ColumnWidthPreset.Narrow
|
||||||
|
: IsWide ? ColumnWidthPreset.Wide
|
||||||
|
: ColumnWidthPreset.Standard;
|
||||||
|
}
|
||||||
|
Close(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Closes without changing anything.
|
||||||
|
/// </summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private Task CancelAsync() => RequestCancelAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pixel widths of the column width presets.
|
||||||
|
/// </summary>
|
||||||
|
public static class ColumnWidths
|
||||||
|
{
|
||||||
|
/// <summary>The narrow preset width.</summary>
|
||||||
|
public const int Narrow = 240;
|
||||||
|
|
||||||
|
/// <summary>The standard (default) preset width.</summary>
|
||||||
|
public const int Standard = 320;
|
||||||
|
|
||||||
|
/// <summary>The wide preset width.</summary>
|
||||||
|
public const int Wide = 480;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the effective column width of the settings.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="settings">The column width settings.</param>
|
||||||
|
/// <returns>The width in pixels.</returns>
|
||||||
|
public static int Resolve(ColumnWidthSettings settings) => settings.Preset switch
|
||||||
|
{
|
||||||
|
ColumnWidthPreset.Narrow => Narrow,
|
||||||
|
ColumnWidthPreset.Wide => Wide,
|
||||||
|
ColumnWidthPreset.Custom => Math.Clamp(settings.CustomPixels, AppConfig.MinColumnPixels, AppConfig.MaxColumnPixels),
|
||||||
|
_ => Standard,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
|
||||||
|
namespace YKanBan.ViewModels.Dialogs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A yes/no confirmation: a message, a confirming button with an action
|
||||||
|
/// specific label (for example "Delete") and a cancel button.
|
||||||
|
/// </summary>
|
||||||
|
public sealed partial class ConfirmDialogViewModel : DialogViewModelBase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes the confirmation.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="message">The localized question.</param>
|
||||||
|
/// <param name="confirmLabel">The localized label of the confirming button.</param>
|
||||||
|
public ConfirmDialogViewModel(string message, string confirmLabel)
|
||||||
|
{
|
||||||
|
Message = message;
|
||||||
|
ConfirmLabel = confirmLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the localized question.
|
||||||
|
/// </summary>
|
||||||
|
public string Message { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the localized label of the confirming button.
|
||||||
|
/// </summary>
|
||||||
|
public string ConfirmLabel { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Closes with a positive result.
|
||||||
|
/// </summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private void Confirm() => Close(true);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Closes with a negative result.
|
||||||
|
/// </summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private Task CancelAsync() => RequestCancelAsync();
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
namespace YKanBan.ViewModels.Dialogs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base class for modal dialog view models shown through <see cref="IDialogService"/>.
|
||||||
|
/// The dialog decides when it closes: it raises <see cref="CloseRequested"/>
|
||||||
|
/// with its result, and a close attempted by the window itself (title-bar close
|
||||||
|
/// button, Escape) is routed to <see cref="RequestCancelAsync"/> so that the
|
||||||
|
/// dialog can veto it, for example to ask before discarding edits.
|
||||||
|
/// </summary>
|
||||||
|
public abstract class DialogViewModelBase : ViewModelBase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Raised when the dialog wants to close; the argument is the dialog result.
|
||||||
|
/// </summary>
|
||||||
|
public event EventHandler<bool>? CloseRequested;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles a cancel request coming from the window (or a cancel button).
|
||||||
|
/// The default closes immediately with a negative result.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A task completing once the request has been handled.</returns>
|
||||||
|
public virtual Task RequestCancelAsync()
|
||||||
|
{
|
||||||
|
Close(false);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Asks the hosting window to close with the given result.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="result">The dialog result.</param>
|
||||||
|
protected void Close(bool result) => CloseRequested?.Invoke(this, result);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
namespace YKanBan.ViewModels.Dialogs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens modal dialogs on behalf of view models, which never reference views.
|
||||||
|
/// Every dialog is owned by the top-most open window, so a dialog opened from
|
||||||
|
/// inside another dialog (nested modal) is centered on and blocks that dialog.
|
||||||
|
/// </summary>
|
||||||
|
public interface IDialogService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Shows a dialog modally and waits until it closes.
|
||||||
|
/// </summary>
|
||||||
|
/// <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>
|
||||||
|
Task<bool> ShowAsync(DialogViewModelBase dialog);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Convenience wrappers for the plain confirmation and message dialogs.
|
||||||
|
/// </summary>
|
||||||
|
public static class DialogServiceExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Asks a yes/no question.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="dialogs">The dialog service.</param>
|
||||||
|
/// <param name="message">The localized question.</param>
|
||||||
|
/// <param name="confirmLabel">The localized label of the confirming button.</param>
|
||||||
|
/// <returns><see langword="true"/> when the user confirmed.</returns>
|
||||||
|
public static Task<bool> ConfirmAsync(this IDialogService dialogs, string message, string confirmLabel) =>
|
||||||
|
dialogs.ShowAsync(new ConfirmDialogViewModel(message, confirmLabel));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shows an informational message with an optional read-only details block.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="dialogs">The dialog service.</param>
|
||||||
|
/// <param name="title">The localized window title.</param>
|
||||||
|
/// <param name="message">The localized message.</param>
|
||||||
|
/// <param name="details">Untranslated diagnostic details, or <see langword="null"/>.</param>
|
||||||
|
/// <returns>A task completing when the dialog closes.</returns>
|
||||||
|
public static Task ShowMessageAsync(this IDialogService dialogs, string title, string message, string? details = null) =>
|
||||||
|
dialogs.ShowAsync(new MessageDialogViewModel(title, message, details));
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
|
||||||
|
namespace YKanBan.ViewModels.Dialogs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A purely informational dialog with a single OK button: a localized message
|
||||||
|
/// plus optional untranslated, copyable diagnostic details.
|
||||||
|
/// </summary>
|
||||||
|
public sealed partial class MessageDialogViewModel : DialogViewModelBase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes the message.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="title">The localized window title.</param>
|
||||||
|
/// <param name="message">The localized message.</param>
|
||||||
|
/// <param name="details">Untranslated diagnostic details, or <see langword="null"/>.</param>
|
||||||
|
public MessageDialogViewModel(string title, string message, string? details)
|
||||||
|
{
|
||||||
|
Title = title;
|
||||||
|
Message = message;
|
||||||
|
Details = details;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the localized window title.
|
||||||
|
/// </summary>
|
||||||
|
public string Title { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the localized message.
|
||||||
|
/// </summary>
|
||||||
|
public string Message { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the diagnostic details, or <see langword="null"/> when there are none.
|
||||||
|
/// </summary>
|
||||||
|
public string? Details { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets whether a details block is shown.
|
||||||
|
/// </summary>
|
||||||
|
public bool HasDetails => !string.IsNullOrEmpty(Details);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Closes the dialog.
|
||||||
|
/// </summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private void Ok() => Close(true);
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using Avalonia.Media.Immutable;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using YKanBan.Models;
|
||||||
|
using YKanBan.Storage;
|
||||||
|
using YKanBan.Storage.Workspace;
|
||||||
|
using YKanBan.ViewModels.Board;
|
||||||
|
|
||||||
|
namespace YKanBan.ViewModels.Dialogs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The nested "add tag" modal of the card editor: a filterable list of the
|
||||||
|
/// existing tags not yet on the card, plus a new-tag entry. Picking a tag closes
|
||||||
|
/// the dialog with it as <see cref="PickedTag"/>.
|
||||||
|
///
|
||||||
|
/// Exception to the edit-dialog unit of work: a tag created here is written to
|
||||||
|
/// the tags table immediately (tags are global entities), so it survives even
|
||||||
|
/// when the outer card edit is cancelled — only the assignment is discarded.
|
||||||
|
/// </summary>
|
||||||
|
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 IReadOnlyList<TagBadgeViewModel> _available;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes the picker.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="repository">The workspace repository; new tags are written through it immediately.</param>
|
||||||
|
/// <param name="assignedTagIds">The tags already on the card, which are not offered again.</param>
|
||||||
|
public TagPickerDialogViewModel(WorkspaceRepository repository, IReadOnlyCollection<long> assignedTagIds)
|
||||||
|
{
|
||||||
|
_repository = repository;
|
||||||
|
IReadOnlyList<TagModel> all = repository.GetTags();
|
||||||
|
_available = all
|
||||||
|
.Where(tag => !assignedTagIds.Contains(tag.Id))
|
||||||
|
.Select(tag => new TagBadgeViewModel(tag))
|
||||||
|
.ToList();
|
||||||
|
FilteredTags = new ObservableCollection<TagBadgeViewModel>(_available);
|
||||||
|
|
||||||
|
newColor = Palette[all.Count % Palette.Length].ToString();
|
||||||
|
UpdateColorPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the tag the user picked or created, once the dialog closed positively.
|
||||||
|
/// </summary>
|
||||||
|
public TagModel? PickedTag { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the available tags matching <see cref="FilterText"/>.
|
||||||
|
/// </summary>
|
||||||
|
public ObservableCollection<TagBadgeViewModel> FilteredTags { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets whether no available tag matches the filter.
|
||||||
|
/// </summary>
|
||||||
|
public bool NoMatches => FilteredTags.Count == 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the list filter: a case-insensitive substring of the tag name.
|
||||||
|
/// </summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private string filterText = "";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the name of the tag to create (not trimmed).
|
||||||
|
/// </summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private string newName = "";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the #RRGGBB color text of the tag to create.
|
||||||
|
/// </summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private string newColor;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the description of the tag to create.
|
||||||
|
/// </summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private string newDescription = "";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the preview of <see cref="NewColor"/>, transparent while it is not valid.
|
||||||
|
/// </summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private IBrush newColorPreview = Brushes.Transparent;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the localized error of the last create attempt, 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>
|
||||||
|
/// Picks an existing tag and closes.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="tag">The picked tag.</param>
|
||||||
|
[RelayCommand]
|
||||||
|
private void Pick(TagBadgeViewModel tag)
|
||||||
|
{
|
||||||
|
PickedTag = tag.Tag;
|
||||||
|
Close(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates the new tag right away and closes with it picked; constraint
|
||||||
|
/// violations and an invalid color keep the dialog open with an error.
|
||||||
|
/// </summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private void Create()
|
||||||
|
{
|
||||||
|
if (!RgbColor.TryParse(NewColor, out RgbColor color))
|
||||||
|
{
|
||||||
|
ErrorMessage = Resources.Error_Tag_InvalidColor;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
PickedTag = _repository.AddTag(NewName, color, NewDescription);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
ErrorMessage = WriteErrorMessages.Describe(exception);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Close(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Closes without picking anything.
|
||||||
|
/// </summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private Task CancelAsync() => RequestCancelAsync();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Re-filters the list.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="value">The new filter text.</param>
|
||||||
|
partial void OnFilterTextChanged(string value)
|
||||||
|
{
|
||||||
|
string folded = SqliteDatabase.Fold(value);
|
||||||
|
FilteredTags.Clear();
|
||||||
|
foreach (TagBadgeViewModel tag in _available)
|
||||||
|
{
|
||||||
|
if (SqliteDatabase.Fold(tag.Name).Contains(folded, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
FilteredTags.Add(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OnPropertyChanged(nameof(NoMatches));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Refreshes the color preview.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="value">The new color text.</param>
|
||||||
|
partial void OnNewColorChanged(string value) => UpdateColorPreview();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Recomputes <see cref="NewColorPreview"/> from <see cref="NewColor"/>.
|
||||||
|
/// </summary>
|
||||||
|
private void UpdateColorPreview() =>
|
||||||
|
NewColorPreview = RgbColor.TryParse(NewColor, out RgbColor color)
|
||||||
|
? new ImmutableSolidColorBrush(Color.FromRgb(color.R, color.G, color.B))
|
||||||
|
: Brushes.Transparent;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using YKanBan.Storage;
|
||||||
|
|
||||||
|
namespace YKanBan.ViewModels.Dialogs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps a failed dialog write to the localized message shown inside the dialog
|
||||||
|
/// that attempted it (the dialog stays open and keeps the user's input). The
|
||||||
|
/// typed schema-constraint exceptions get dedicated messages; anything else
|
||||||
|
/// gets a generic one carrying the untranslated exception message.
|
||||||
|
/// </summary>
|
||||||
|
public static class WriteErrorMessages
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the localized message for a failed write.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="exception">The exception thrown by the write.</param>
|
||||||
|
/// <returns>The localized message.</returns>
|
||||||
|
public static string Describe(Exception exception)
|
||||||
|
{
|
||||||
|
CultureInfo? culture = Resources.Culture;
|
||||||
|
return exception switch
|
||||||
|
{
|
||||||
|
NameRequiredException { Entity: NamedEntity.Column } => Resources.Error_Column_TitleRequired,
|
||||||
|
NameRequiredException { Entity: NamedEntity.Tag } => Resources.Error_Tag_NameRequired,
|
||||||
|
DuplicateNameException { Entity: NamedEntity.Column } duplicate =>
|
||||||
|
string.Format(culture, Resources.Error_Column_Duplicate, duplicate.Name),
|
||||||
|
DuplicateNameException { Entity: NamedEntity.Tag } duplicate =>
|
||||||
|
string.Format(culture, Resources.Error_Tag_Duplicate, duplicate.Name),
|
||||||
|
_ => string.Format(culture, Resources.Error_SaveFailed, exception.Message),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ using YKanBan.Launching;
|
|||||||
using YKanBan.Services;
|
using YKanBan.Services;
|
||||||
using YKanBan.Storage;
|
using YKanBan.Storage;
|
||||||
using YKanBan.Storage.Workspace;
|
using YKanBan.Storage.Workspace;
|
||||||
|
using YKanBan.ViewModels.Dialogs;
|
||||||
using YKanBan.ViewModels.ErrorPages;
|
using YKanBan.ViewModels.ErrorPages;
|
||||||
|
|
||||||
namespace YKanBan.ViewModels;
|
namespace YKanBan.ViewModels;
|
||||||
@@ -16,15 +17,18 @@ namespace YKanBan.ViewModels;
|
|||||||
public sealed partial class MainWindowViewModel : ViewModelBase
|
public sealed partial class MainWindowViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
private readonly AppServices _services;
|
private readonly AppServices _services;
|
||||||
|
private readonly IDialogService _dialogs;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes the view model and resolves the workspace state.
|
/// Initializes the view model and resolves the workspace state.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="services">The process composition root.</param>
|
/// <param name="services">The process composition root.</param>
|
||||||
|
/// <param name="dialogs">The modal dialog service used by the board.</param>
|
||||||
/// <param name="arguments">The command-line arguments (excluding the executable).</param>
|
/// <param name="arguments">The command-line arguments (excluding the executable).</param>
|
||||||
public MainWindowViewModel(AppServices services, IReadOnlyList<string> arguments)
|
public MainWindowViewModel(AppServices services, IDialogService dialogs, IReadOnlyList<string> arguments)
|
||||||
{
|
{
|
||||||
_services = services;
|
_services = services;
|
||||||
|
_dialogs = dialogs;
|
||||||
ShowSettings = new Interaction<Unit>();
|
ShowSettings = new Interaction<Unit>();
|
||||||
ShowAbout = new Interaction<Unit>();
|
ShowAbout = new Interaction<Unit>();
|
||||||
InitializeWorkspace = new Interaction<Unit>();
|
InitializeWorkspace = new Interaction<Unit>();
|
||||||
@@ -104,7 +108,7 @@ public sealed partial class MainWindowViewModel : ViewModelBase
|
|||||||
private void OpenWorkspace(string folderPath)
|
private void OpenWorkspace(string folderPath)
|
||||||
{
|
{
|
||||||
WorkspaceSession session = _services.OpenWorkspace(folderPath);
|
WorkspaceSession session = _services.OpenWorkspace(folderPath);
|
||||||
Content = new WorkspaceViewModel(session, ShowSettings, ShowAbout);
|
Content = new WorkspaceViewModel(session, ShowSettings, ShowAbout, _services.Config, _dialogs);
|
||||||
Title = $"{new DirectoryInfo(folderPath).Name} - {Resources.App_Name}";
|
Title = $"{new DirectoryInfo(folderPath).Name} - {Resources.App_Name}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using YKanBan.Storage.AppData;
|
||||||
using YKanBan.Storage.Workspace;
|
using YKanBan.Storage.Workspace;
|
||||||
|
using YKanBan.ViewModels.Board;
|
||||||
|
using YKanBan.ViewModels.Dialogs;
|
||||||
|
|
||||||
namespace YKanBan.ViewModels;
|
namespace YKanBan.ViewModels;
|
||||||
|
|
||||||
@@ -19,16 +22,21 @@ public sealed partial class WorkspaceViewModel : ViewModelBase
|
|||||||
/// <param name="session">The open workspace session.</param>
|
/// <param name="session">The open workspace session.</param>
|
||||||
/// <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="dialogs">The modal dialog service of the board.</param>
|
||||||
public WorkspaceViewModel(
|
public WorkspaceViewModel(
|
||||||
WorkspaceSession session,
|
WorkspaceSession session,
|
||||||
Interaction<Unit> showSettings,
|
Interaction<Unit> showSettings,
|
||||||
Interaction<Unit> showAbout)
|
Interaction<Unit> showAbout,
|
||||||
|
AppConfig config,
|
||||||
|
IDialogService dialogs)
|
||||||
{
|
{
|
||||||
Session = session;
|
Session = session;
|
||||||
WorkspaceName = new DirectoryInfo(session.FolderPath).Name;
|
WorkspaceName = new DirectoryInfo(session.FolderPath).Name;
|
||||||
WorkspacePath = session.FolderPath;
|
WorkspacePath = session.FolderPath;
|
||||||
_showSettings = showSettings;
|
_showSettings = showSettings;
|
||||||
_showAbout = showAbout;
|
_showAbout = showAbout;
|
||||||
|
Board = new BoardViewModel(session.Repository, config, dialogs);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -46,6 +54,11 @@ public sealed partial class WorkspaceViewModel : ViewModelBase
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string WorkspacePath { get; }
|
public string WorkspacePath { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the board tab.
|
||||||
|
/// </summary>
|
||||||
|
public BoardViewModel Board { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Opens the settings dialog through the registered interaction.
|
/// Opens the settings dialog through the registered interaction.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:YKanBan.ViewModels.Board"
|
||||||
|
xmlns:vmBase="using:YKanBan.ViewModels"
|
||||||
|
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.Board.BoardView"
|
||||||
|
x:DataType="vm:BoardViewModel"
|
||||||
|
x:Name="Root">
|
||||||
|
|
||||||
|
<Grid RowDefinitions="Auto,*" RowSpacing="8">
|
||||||
|
|
||||||
|
<!-- Toolbar. The search bar joins on the left with the search feature. -->
|
||||||
|
<StackPanel Grid.Row="0" Orientation="Horizontal" HorizontalAlignment="Right" 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>
|
||||||
|
|
||||||
|
<!-- Columns left to right; each column scrolls its own virtualized card list. -->
|
||||||
|
<ScrollViewer Grid.Row="1" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
|
||||||
|
<ItemsControl ItemsSource="{Binding Columns}">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="12"/>
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:ColumnViewModel">
|
||||||
|
<Border Width="{Binding #Root.((vm:BoardViewModel)DataContext).ColumnWidth}"
|
||||||
|
Background="{DynamicResource ColumnBackgroundBrush}"
|
||||||
|
BorderBrush="{DynamicResource PanelBorderBrush}" BorderThickness="1"
|
||||||
|
CornerRadius="6" Padding="8">
|
||||||
|
<Grid RowDefinitions="Auto,*" RowSpacing="8">
|
||||||
|
|
||||||
|
<!-- Column header: title, count, description, new card, edit/delete menu. -->
|
||||||
|
<Grid Grid.Row="0" ColumnDefinitions="*,Auto,Auto">
|
||||||
|
<StackPanel Grid.Column="0" Spacing="2" VerticalAlignment="Center">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
|
<TextBlock Text="{Binding Title}" FontWeight="SemiBold"
|
||||||
|
TextTrimming="CharacterEllipsis"/>
|
||||||
|
<TextBlock Text="{Binding CardCount}" Opacity="0.6"/>
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Text="{Binding Description}" IsVisible="{Binding HasDescription}"
|
||||||
|
FontSize="12" Opacity="0.7" TextWrapping="Wrap" MaxLines="3"
|
||||||
|
TextTrimming="CharacterEllipsis"/>
|
||||||
|
</StackPanel>
|
||||||
|
<Button Grid.Column="1" Command="{Binding AddCardCommand}" VerticalAlignment="Top"
|
||||||
|
Theme="{StaticResource BorderlessButton}" Padding="4"
|
||||||
|
ToolTip.Tip="{x:Static lang:Resources.Board_AddCard}">
|
||||||
|
<icons:PackIconLucide Kind="Plus" Width="16" Height="16"/>
|
||||||
|
</Button>
|
||||||
|
<Button Grid.Column="2" VerticalAlignment="Top"
|
||||||
|
Theme="{StaticResource BorderlessButton}" Padding="4"
|
||||||
|
ToolTip.Tip="{x:Static lang:Resources.Menu_More}">
|
||||||
|
<icons:PackIconLucide Kind="Ellipsis" Width="16" Height="16"/>
|
||||||
|
<Button.Flyout>
|
||||||
|
<MenuFlyout Placement="BottomEdgeAlignedRight">
|
||||||
|
<MenuItem Header="{x:Static lang:Resources.Menu_Edit}" Command="{Binding EditCommand}"/>
|
||||||
|
<MenuItem Header="{x:Static lang:Resources.Menu_Delete}" Command="{Binding DeleteCommand}"/>
|
||||||
|
</MenuFlyout>
|
||||||
|
</Button.Flyout>
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Cards top to bottom, UI-virtualized for large columns. -->
|
||||||
|
<ItemsControl Grid.Row="1" ItemsSource="{Binding Cards}">
|
||||||
|
<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:CardViewModel">
|
||||||
|
<Panel Margin="0,0,0,8">
|
||||||
|
<!-- The card body opens the editor. -->
|
||||||
|
<Button Command="{Binding EditCommand}"
|
||||||
|
HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch"
|
||||||
|
VerticalContentAlignment="Top"
|
||||||
|
Background="{DynamicResource CardBackgroundBrush}"
|
||||||
|
Foreground="{DynamicResource CardForegroundBrush}"
|
||||||
|
FontWeight="Normal"
|
||||||
|
BorderBrush="{DynamicResource PanelBorderBrush}" BorderThickness="1"
|
||||||
|
CornerRadius="4" Padding="10,8" Cursor="Hand">
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
<TextBlock TextWrapping="Wrap" Margin="0,0,24,0">
|
||||||
|
<Run Text="{Binding IdText}" FontWeight="Bold"/><Run Text="{Binding TitleSuffix}"/>
|
||||||
|
</TextBlock>
|
||||||
|
<TextBlock Text="{Binding Excerpt}" IsVisible="{Binding HasExcerpt}"
|
||||||
|
TextWrapping="Wrap" FontSize="12" Opacity="0.8"
|
||||||
|
FontWeight="Normal"/>
|
||||||
|
<ItemsControl ItemsSource="{Binding Tags}" IsVisible="{Binding HasTags}">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<WrapPanel/>
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:TagBadgeViewModel">
|
||||||
|
<Border Background="{Binding Background}" CornerRadius="10"
|
||||||
|
Padding="8,1" Margin="0,0,4,4">
|
||||||
|
<TextBlock Text="{Binding Name}" Foreground="{Binding Foreground}"
|
||||||
|
FontSize="11" FontWeight="Normal"/>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<!-- Card menu: delete, move to column. -->
|
||||||
|
<Button HorizontalAlignment="Right" VerticalAlignment="Top" Margin="0,4,4,0"
|
||||||
|
Theme="{StaticResource BorderlessButton}" Padding="4"
|
||||||
|
ToolTip.Tip="{x:Static lang:Resources.Menu_More}">
|
||||||
|
<icons:PackIconLucide Kind="Ellipsis" Width="14" Height="14"/>
|
||||||
|
<Button.Flyout>
|
||||||
|
<MenuFlyout Placement="BottomEdgeAlignedRight">
|
||||||
|
<MenuItem Header="{x:Static lang:Resources.Menu_Delete}"
|
||||||
|
Command="{Binding DeleteCommand}"/>
|
||||||
|
<MenuItem Header="{x:Static lang:Resources.Menu_MoveTo}"
|
||||||
|
ItemsSource="{Binding MoveTargets}">
|
||||||
|
<MenuItem.ItemContainerTheme>
|
||||||
|
<ControlTheme TargetType="MenuItem"
|
||||||
|
BasedOn="{StaticResource {x:Type MenuItem}}"
|
||||||
|
x:DataType="vm:MoveTargetViewModel">
|
||||||
|
<Setter Property="Header" Value="{Binding Title}"/>
|
||||||
|
<Setter Property="Command" Value="{Binding Command}"/>
|
||||||
|
<Setter Property="CommandParameter" Value="{Binding ColumnId}"/>
|
||||||
|
<Setter Property="IsEnabled" Value="{Binding IsEnabled}"/>
|
||||||
|
</ControlTheme>
|
||||||
|
</MenuItem.ItemContainerTheme>
|
||||||
|
</MenuItem>
|
||||||
|
</MenuFlyout>
|
||||||
|
</Button.Flyout>
|
||||||
|
</Button>
|
||||||
|
</Panel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Grid>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace YKanBan.Views.Board;
|
||||||
|
|
||||||
|
public partial class BoardView : UserControl
|
||||||
|
{
|
||||||
|
public BoardView()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using YKanBan.ViewModels.Dialogs;
|
||||||
|
using YKanBan.Views.Dialogs;
|
||||||
|
|
||||||
|
namespace YKanBan.Views;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shows dialog view models as modal windows. A <c>XxxDialogViewModel</c> in
|
||||||
|
/// <c>YKanBan.ViewModels.Dialogs</c> is shown by the <c>XxxDialog</c> window in
|
||||||
|
/// <c>YKanBan.Views.Dialogs</c>. Each dialog is owned by the top-most open
|
||||||
|
/// window, so nested modals (the tag picker inside the card editor, a discard
|
||||||
|
/// confirmation above it) center on their direct parent.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DialogService : IDialogService
|
||||||
|
{
|
||||||
|
private readonly Stack<Window> _owners = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets the main window as the bottom of the owner stack.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="mainWindow">The main window.</param>
|
||||||
|
public void Attach(Window mainWindow)
|
||||||
|
{
|
||||||
|
_owners.Clear();
|
||||||
|
_owners.Push(mainWindow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<bool> ShowAsync(DialogViewModelBase dialog)
|
||||||
|
{
|
||||||
|
if (!_owners.TryPeek(out Window? owner))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("The dialog service has no main window attached.");
|
||||||
|
}
|
||||||
|
|
||||||
|
DialogWindow window = CreateWindow(dialog);
|
||||||
|
window.DataContext = dialog;
|
||||||
|
|
||||||
|
// FlowDirection does not inherit across windows, so every modal gets it explicitly.
|
||||||
|
window.FlowDirection = App.UiFlowDirection;
|
||||||
|
|
||||||
|
_owners.Push(window);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await window.ShowDialog<bool>(owner);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_owners.Pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates the window class matching a dialog view model by naming convention.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="dialog">The dialog view model.</param>
|
||||||
|
/// <returns>The window.</returns>
|
||||||
|
private static DialogWindow CreateWindow(DialogViewModelBase dialog)
|
||||||
|
{
|
||||||
|
string viewName = typeof(ConfirmDialog).Namespace + "." + dialog.GetType().Name.Replace("ViewModel", "", StringComparison.Ordinal);
|
||||||
|
Type? viewType = typeof(DialogService).Assembly.GetType(viewName);
|
||||||
|
if (viewType is null || !typeof(DialogWindow).IsAssignableFrom(viewType))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"No dialog window '{viewName}' for {dialog.GetType().Name}.");
|
||||||
|
}
|
||||||
|
return (DialogWindow)Activator.CreateInstance(viewType)!;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<dialogs:DialogWindow xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:YKanBan.ViewModels.Dialogs"
|
||||||
|
xmlns:board="using:YKanBan.ViewModels.Board"
|
||||||
|
xmlns:dialogs="using:YKanBan.Views.Dialogs"
|
||||||
|
xmlns:lang="using:YKanBan"
|
||||||
|
xmlns:icons="using:IconPacks.Avalonia.Lucide"
|
||||||
|
x:Class="YKanBan.Views.Dialogs.CardEditorDialog"
|
||||||
|
x:DataType="vm:CardEditorDialogViewModel"
|
||||||
|
x:Name="Root"
|
||||||
|
Title="{Binding Title}"
|
||||||
|
SizeToContent="Manual" CanResize="True"
|
||||||
|
Width="600" Height="560" MinWidth="440" MinHeight="420"
|
||||||
|
WindowStartupLocation="CenterOwner">
|
||||||
|
|
||||||
|
<Grid Margin="20" RowDefinitions="Auto,Auto,*,Auto,Auto,Auto,Auto" RowSpacing="12">
|
||||||
|
|
||||||
|
<!-- Read-only id and timestamps; not shown while the card does not exist yet. -->
|
||||||
|
<WrapPanel Grid.Row="0" IsVisible="{Binding IsExisting}" Orientation="Horizontal">
|
||||||
|
<TextBlock Text="{Binding IdText}" FontWeight="Bold" Margin="0,0,16,0"/>
|
||||||
|
<TextBlock Text="{Binding CreatedText}" Opacity="0.7" Margin="0,0,16,0"/>
|
||||||
|
<TextBlock Text="{Binding ModifiedText}" Opacity="0.7"/>
|
||||||
|
</WrapPanel>
|
||||||
|
|
||||||
|
<StackPanel Grid.Row="1" Spacing="4">
|
||||||
|
<TextBlock Text="{x:Static lang:Resources.Field_Title}"/>
|
||||||
|
<TextBox Text="{Binding CardTitle}" Watermark="{x:Static lang:Resources.CardEditor_TitleWatermark}"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Grid Grid.Row="2" RowDefinitions="Auto,*" RowSpacing="4">
|
||||||
|
<TextBlock Grid.Row="0" Text="{x:Static lang:Resources.Field_Content}"/>
|
||||||
|
<TextBox Grid.Row="1" Text="{Binding Content}" AcceptsReturn="True" TextWrapping="Wrap"
|
||||||
|
VerticalAlignment="Stretch"
|
||||||
|
VerticalContentAlignment="Top"/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<StackPanel Grid.Row="3" Spacing="6">
|
||||||
|
<TextBlock Text="{x:Static lang:Resources.Field_Tags}"/>
|
||||||
|
<ItemsControl ItemsSource="{Binding Tags}">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<WrapPanel/>
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="board:TagBadgeViewModel">
|
||||||
|
<Border Background="{Binding Background}" CornerRadius="10" Padding="8,2,4,2"
|
||||||
|
Margin="0,0,6,6" ToolTip.Tip="{Binding Description}">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="2">
|
||||||
|
<TextBlock Text="{Binding Name}" Foreground="{Binding Foreground}"
|
||||||
|
VerticalAlignment="Center" FontSize="12"/>
|
||||||
|
<Button Command="{Binding #Root.((vm:CardEditorDialogViewModel)DataContext).RemoveTagCommand}"
|
||||||
|
CommandParameter="{Binding}"
|
||||||
|
Theme="{StaticResource BorderlessButton}" Padding="2"
|
||||||
|
ToolTip.Tip="{x:Static lang:Resources.CardEditor_RemoveTag}">
|
||||||
|
<icons:PackIconLucide Kind="X" Width="12" Height="12" Foreground="{Binding Foreground}"/>
|
||||||
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
<Button Command="{Binding AddTagCommand}" HorizontalAlignment="Left">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
|
<icons:PackIconLucide Kind="Plus" Width="14" Height="14"/>
|
||||||
|
<TextBlock Text="{x:Static lang:Resources.CardEditor_AddTag}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="4" Text="{Binding ErrorMessage}" IsVisible="{Binding HasError}"
|
||||||
|
Foreground="{DynamicResource ErrorTextBrush}" TextWrapping="Wrap"/>
|
||||||
|
|
||||||
|
<StackPanel Grid.Row="6" Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8">
|
||||||
|
<Button Content="{x:Static lang:Resources.Common_Ok}" Command="{Binding ConfirmCommand}"
|
||||||
|
Classes="Primary" MinWidth="90"/>
|
||||||
|
<Button Content="{x:Static lang:Resources.Common_Cancel}" Command="{Binding CancelCommand}"
|
||||||
|
MinWidth="90" IsCancel="True"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</dialogs:DialogWindow>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace YKanBan.Views.Dialogs;
|
||||||
|
|
||||||
|
public partial class CardEditorDialog : DialogWindow
|
||||||
|
{
|
||||||
|
public CardEditorDialog()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<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.ColumnEditorDialog"
|
||||||
|
x:DataType="vm:ColumnEditorDialogViewModel"
|
||||||
|
Title="{Binding Title}"
|
||||||
|
Width="440"
|
||||||
|
WindowStartupLocation="CenterOwner">
|
||||||
|
|
||||||
|
<StackPanel Margin="20" Spacing="14">
|
||||||
|
<StackPanel Spacing="4">
|
||||||
|
<TextBlock Text="{x:Static lang:Resources.Field_Title}"/>
|
||||||
|
<TextBox Text="{Binding ColumnTitle}"/>
|
||||||
|
</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 ColumnEditorDialog : DialogWindow
|
||||||
|
{
|
||||||
|
public ColumnEditorDialog()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<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.ColumnWidthDialog"
|
||||||
|
x:DataType="vm:ColumnWidthDialogViewModel"
|
||||||
|
Title="{x:Static lang:Resources.ColumnWidth_Title}"
|
||||||
|
Width="360"
|
||||||
|
WindowStartupLocation="CenterOwner">
|
||||||
|
|
||||||
|
<StackPanel Margin="20" Spacing="10">
|
||||||
|
<RadioButton GroupName="Width" Content="{Binding NarrowLabel}" IsChecked="{Binding IsNarrow}"/>
|
||||||
|
<RadioButton GroupName="Width" Content="{Binding StandardLabel}" IsChecked="{Binding IsStandard}"/>
|
||||||
|
<RadioButton GroupName="Width" Content="{Binding WideLabel}" IsChecked="{Binding IsWide}"/>
|
||||||
|
<RadioButton GroupName="Width" Content="{x:Static lang:Resources.ColumnWidth_Custom}" IsChecked="{Binding IsCustom}"/>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8" Margin="28,0,0,0" IsEnabled="{Binding IsCustom}">
|
||||||
|
<NumericUpDown Value="{Binding CustomPixels}" Minimum="{Binding MinPixels}" Maximum="{Binding MaxPixels}"
|
||||||
|
Increment="10" FormatString="0" Width="140"/>
|
||||||
|
<TextBlock Text="{Binding RangeLabel}" VerticalAlignment="Center" Opacity="0.7"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8" Margin="0,8,0,0">
|
||||||
|
<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 ColumnWidthDialog : DialogWindow
|
||||||
|
{
|
||||||
|
public ColumnWidthDialog()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<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.ConfirmDialog"
|
||||||
|
x:DataType="vm:ConfirmDialogViewModel"
|
||||||
|
Title="{x:Static lang:Resources.Confirm_Title}"
|
||||||
|
MinWidth="360" MaxWidth="520"
|
||||||
|
WindowStartupLocation="CenterOwner">
|
||||||
|
|
||||||
|
<StackPanel Margin="20" Spacing="16">
|
||||||
|
<TextBlock Text="{Binding Message}" TextWrapping="Wrap"/>
|
||||||
|
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8">
|
||||||
|
<Button Content="{Binding ConfirmLabel}" Command="{Binding ConfirmCommand}"
|
||||||
|
Classes="Danger" 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 ConfirmDialog : DialogWindow
|
||||||
|
{
|
||||||
|
public ConfirmDialog()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using YKanBan.ViewModels.Dialogs;
|
||||||
|
|
||||||
|
namespace YKanBan.Views.Dialogs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base class of the windows shown by <see cref="DialogService"/>: a standard
|
||||||
|
/// Avalonia window centered on its owner that closes when its
|
||||||
|
/// <see cref="DialogViewModelBase"/> asks, with the result as the dialog result.
|
||||||
|
/// The title bar close button is routed to the view model's cancel path, so a
|
||||||
|
/// dialog with unsaved changes can ask before discarding them.
|
||||||
|
/// </summary>
|
||||||
|
public class DialogWindow : Window
|
||||||
|
{
|
||||||
|
private DialogViewModelBase? _viewModel;
|
||||||
|
private bool _closeRequested;
|
||||||
|
private bool _cancelPending;
|
||||||
|
|
||||||
|
public DialogWindow()
|
||||||
|
{
|
||||||
|
WindowStartupLocation = WindowStartupLocation.CenterOwner;
|
||||||
|
ShowInTaskbar = false;
|
||||||
|
CanResize = false;
|
||||||
|
SizeToContent = SizeToContent.WidthAndHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Styles the subclasses as plain windows.</summary>
|
||||||
|
protected override Type StyleKeyOverride => typeof(Window);
|
||||||
|
|
||||||
|
protected override void OnDataContextChanged(EventArgs e)
|
||||||
|
{
|
||||||
|
base.OnDataContextChanged(e);
|
||||||
|
if (_viewModel is not null)
|
||||||
|
{
|
||||||
|
_viewModel.CloseRequested -= OnCloseRequested;
|
||||||
|
}
|
||||||
|
_viewModel = DataContext as DialogViewModelBase;
|
||||||
|
if (_viewModel is not null)
|
||||||
|
{
|
||||||
|
_viewModel.CloseRequested += OnCloseRequested;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async void OnClosing(WindowClosingEventArgs e)
|
||||||
|
{
|
||||||
|
base.OnClosing(e);
|
||||||
|
|
||||||
|
// Only the user's close button goes through the view model; shutdown and
|
||||||
|
// owner closing are let through.
|
||||||
|
if (_closeRequested || _viewModel is null || e.CloseReason != WindowCloseReason.WindowClosing)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
e.Cancel = true;
|
||||||
|
if (_cancelPending)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_cancelPending = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _viewModel.RequestCancelAsync();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_cancelPending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnClosed(EventArgs e)
|
||||||
|
{
|
||||||
|
base.OnClosed(e);
|
||||||
|
if (_viewModel is not null)
|
||||||
|
{
|
||||||
|
_viewModel.CloseRequested -= OnCloseRequested;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnCloseRequested(object? sender, bool result)
|
||||||
|
{
|
||||||
|
_closeRequested = true;
|
||||||
|
Close(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<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.MessageDialog"
|
||||||
|
x:DataType="vm:MessageDialogViewModel"
|
||||||
|
Title="{Binding Title}"
|
||||||
|
MinWidth="360" MaxWidth="560"
|
||||||
|
WindowStartupLocation="CenterOwner">
|
||||||
|
|
||||||
|
<StackPanel Margin="20" Spacing="12">
|
||||||
|
<TextBlock Text="{Binding Message}" TextWrapping="Wrap"/>
|
||||||
|
<SelectableTextBlock Text="{Binding Details}" IsVisible="{Binding HasDetails}"
|
||||||
|
TextWrapping="Wrap" FontSize="12" Opacity="0.7"/>
|
||||||
|
<Button Content="{x:Static lang:Resources.Common_Ok}" Command="{Binding OkCommand}"
|
||||||
|
HorizontalAlignment="Right" MinWidth="90" IsDefault="True" IsCancel="True"/>
|
||||||
|
</StackPanel>
|
||||||
|
</dialogs:DialogWindow>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace YKanBan.Views.Dialogs;
|
||||||
|
|
||||||
|
public partial class MessageDialog : DialogWindow
|
||||||
|
{
|
||||||
|
public MessageDialog()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<dialogs:DialogWindow xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:YKanBan.ViewModels.Dialogs"
|
||||||
|
xmlns:board="using:YKanBan.ViewModels.Board"
|
||||||
|
xmlns:dialogs="using:YKanBan.Views.Dialogs"
|
||||||
|
xmlns:lang="using:YKanBan"
|
||||||
|
xmlns:icons="using:IconPacks.Avalonia.Lucide"
|
||||||
|
x:Class="YKanBan.Views.Dialogs.TagPickerDialog"
|
||||||
|
x:DataType="vm:TagPickerDialogViewModel"
|
||||||
|
x:Name="Root"
|
||||||
|
Title="{x:Static lang:Resources.TagPicker_Title}"
|
||||||
|
SizeToContent="Manual" CanResize="True"
|
||||||
|
Width="460" Height="560" MinWidth="380" MinHeight="440"
|
||||||
|
WindowStartupLocation="CenterOwner">
|
||||||
|
|
||||||
|
<Grid Margin="20" RowDefinitions="Auto,*,Auto,Auto" RowSpacing="12">
|
||||||
|
|
||||||
|
<TextBox Grid.Row="0" Text="{Binding FilterText}" Watermark="{x:Static lang:Resources.TagPicker_FilterWatermark}">
|
||||||
|
<TextBox.InnerLeftContent>
|
||||||
|
<icons:PackIconLucide Kind="Search" Width="14" Height="14" Margin="8,0,0,0" Opacity="0.6"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</TextBox.InnerLeftContent>
|
||||||
|
</TextBox>
|
||||||
|
|
||||||
|
<!-- Existing tags not yet on the card; one click picks a tag. -->
|
||||||
|
<Border Grid.Row="1" BorderBrush="{DynamicResource PanelBorderBrush}" BorderThickness="1" CornerRadius="4">
|
||||||
|
<Panel>
|
||||||
|
<ListBox ItemsSource="{Binding FilteredTags}" Background="Transparent">
|
||||||
|
<ListBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="board:TagBadgeViewModel">
|
||||||
|
<Button Command="{Binding #Root.((vm:TagPickerDialogViewModel)DataContext).PickCommand}"
|
||||||
|
CommandParameter="{Binding}"
|
||||||
|
Theme="{StaticResource BorderlessButton}"
|
||||||
|
HorizontalAlignment="Stretch" HorizontalContentAlignment="Left" Padding="4">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
|
<Border Background="{Binding Background}" CornerRadius="10" Padding="8,2">
|
||||||
|
<TextBlock Text="{Binding Name}" Foreground="{Binding Foreground}" FontSize="12"/>
|
||||||
|
</Border>
|
||||||
|
<TextBlock Text="{Binding Description}" Opacity="0.7" VerticalAlignment="Center"
|
||||||
|
TextTrimming="CharacterEllipsis"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListBox.ItemTemplate>
|
||||||
|
</ListBox>
|
||||||
|
<TextBlock Text="{x:Static lang:Resources.TagPicker_NoMatches}" IsVisible="{Binding NoMatches}"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center" Opacity="0.6"/>
|
||||||
|
</Panel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- New tag: written to the tags table right away, even if the card edit is cancelled. -->
|
||||||
|
<StackPanel Grid.Row="2" Spacing="8">
|
||||||
|
<TextBlock Text="{x:Static lang:Resources.TagPicker_NewTag}" FontWeight="SemiBold"/>
|
||||||
|
<Grid ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8">
|
||||||
|
<TextBox Grid.Column="0" Text="{Binding NewName}" Watermark="{x:Static lang:Resources.Field_Name}"/>
|
||||||
|
<TextBox Grid.Column="1" Text="{Binding NewColor}" Width="100"
|
||||||
|
Watermark="{x:Static lang:Resources.Field_Color}"/>
|
||||||
|
<Border Grid.Column="2" Width="28" Height="28" CornerRadius="4"
|
||||||
|
Background="{Binding NewColorPreview}"
|
||||||
|
BorderBrush="{DynamicResource PanelBorderBrush}" BorderThickness="1"/>
|
||||||
|
</Grid>
|
||||||
|
<TextBox Text="{Binding NewDescription}" Watermark="{x:Static lang:Resources.Field_Description}"/>
|
||||||
|
<TextBlock Text="{Binding ErrorMessage}" IsVisible="{Binding HasError}"
|
||||||
|
Foreground="{DynamicResource ErrorTextBrush}" TextWrapping="Wrap"/>
|
||||||
|
<Button Command="{Binding CreateCommand}" HorizontalAlignment="Left">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
|
<icons:PackIconLucide Kind="Plus" Width="14" Height="14"/>
|
||||||
|
<TextBlock Text="{x:Static lang:Resources.TagPicker_Create}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Button Grid.Row="3" Content="{x:Static lang:Resources.Common_Cancel}" Command="{Binding CancelCommand}"
|
||||||
|
HorizontalAlignment="Right" MinWidth="90" IsCancel="True"/>
|
||||||
|
</Grid>
|
||||||
|
</dialogs:DialogWindow>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace YKanBan.Views.Dialogs;
|
||||||
|
|
||||||
|
public partial class TagPickerDialog : DialogWindow
|
||||||
|
{
|
||||||
|
public TagPickerDialog()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,9 +48,11 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- Tags and board tabs; their contents arrive with the corresponding features. -->
|
<!-- Board and tags tabs; the tags tab is filled in by the tag feature. -->
|
||||||
<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"/>
|
||||||
|
</TabItem>
|
||||||
<TabItem Header="{x:Static lang:Resources.Tab_Tags}"/>
|
<TabItem Header="{x:Static lang:Resources.Tab_Tags}"/>
|
||||||
</TabControl>
|
</TabControl>
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -176,7 +176,7 @@ card_tags: (card_id, tag_id) 复合主键,双向 ON DELETE CASCADE
|
|||||||
**看板选项卡**:
|
**看板选项卡**:
|
||||||
|
|
||||||
- 搜索栏(点击搜索按钮才执行,无实时搜索,带 × 一键清空);右侧依次:新增列按钮、列宽调整按钮、排序 combobox
|
- 搜索栏(点击搜索按钮才执行,无实时搜索,带 × 一键清空);右侧依次:新增列按钮、列宽调整按钮、排序 combobox
|
||||||
- 列宽:模态选择预设窄/标准/宽或手动像素值(范围 160–720,默认"标准"= 320),全局生效,持久化到 app.json
|
- 列宽:模态选择预设窄/标准/宽或手动像素值(窄 = 240 / 标准 = 320 / 宽 = 480;手动范围 160–720,默认"标准"),全局生效,持久化到 app.json
|
||||||
- 看板区域(与 GitHub Projects 基本一致):列左→右横排,卡片上→下展开;**无拖拽**
|
- 看板区域(与 GitHub Projects 基本一致):列左→右横排,卡片上→下展开;**无拖拽**
|
||||||
- 列内卡片列表必须使用 UI 虚拟化(如 `VirtualizingStackPanel`),满足 10000 张卡片量级的流畅显示
|
- 列内卡片列表必须使用 UI 虚拟化(如 `VirtualizingStackPanel`),满足 10000 张卡片量级的流畅显示
|
||||||
- 列右上角:+新建卡片、⋯菜单(编辑、删除);"编辑" = 标题 + description 编辑模态
|
- 列右上角:+新建卡片、⋯菜单(编辑、删除);"编辑" = 标题 + description 编辑模态
|
||||||
@@ -372,8 +372,8 @@ phrase := '"' 任意文本 '"' (内部支持转义:\" → 字面引号,
|
|||||||
| M2 仓库层 | 已完成 | CRUD/移动/级联/标签指派、单连接生命周期 | 单测 |
|
| M2 仓库层 | 已完成 | CRUD/移动/级联/标签指派、单连接生命周期 | 单测 |
|
||||||
| M3 搜索与导出 | 已完成 | Pidgin 解析器(严格校验,非法输入返回英文错误消息)、查询→SQL 编译、Markdown 导出(直接读 ResX,en / zh-Hans 输出均有测试) | 单测(成功与错误全分支) |
|
| M3 搜索与导出 | 已完成 | Pidgin 解析器(严格校验,非法输入返回英文错误消息)、查询→SQL 编译、Markdown 导出(直接读 ResX,en / zh-Hans 输出均有测试) | 单测(成功与错误全分支) |
|
||||||
| 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 结构自查通过;实际触发与产物验证视网络条件推迟 |
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user