From 0fdd94d109b2a5c2b8a6d01affe9e8e6ff5554cb Mon Sep 17 00:00:00 2001 From: yyc12345 Date: Sun, 4 Oct 2026 09:43:25 +0800 Subject: [PATCH] feat: add tag manager, search filtering and export --- .../Workspace/WorkspaceRepositoryTests.cs | 12 +++ YKanBan/App.axaml | 1 + YKanBan/Assets/Locales/Resources.resx | 54 ++++++++++ YKanBan/Assets/Locales/Resources.zh-Hans.resx | 54 ++++++++++ .../Storage/Workspace/WorkspaceRepository.cs | 23 ++++ YKanBan/ViewModels/Board/BoardViewModel.cs | 85 +++++++++++++-- .../Dialogs/DeleteTagConfirmViewModel.cs | 29 +++++ .../ViewModels/Dialogs/ExportDoneViewModel.cs | 25 +++++ .../Dialogs/SearchErrorViewModel.cs | 27 +++++ .../ViewModels/Dialogs/TagEditorViewModel.cs | 97 +++++++++++++++++ YKanBan/ViewModels/Tags/TagItemViewModel.cs | 78 ++++++++++++++ YKanBan/ViewModels/Tags/TagsViewModel.cs | 102 ++++++++++++++++++ YKanBan/ViewModels/WorkspaceViewModel.cs | 21 +++- YKanBan/Views/Board/BoardView.axaml | 26 +++-- YKanBan/Views/Board/BoardView.axaml.cs | 19 +++- .../Dialogs/DeleteTagConfirmDialog.axaml | 24 +++++ .../Dialogs/DeleteTagConfirmDialog.axaml.cs | 18 ++++ YKanBan/Views/Dialogs/ExportDoneDialog.axaml | 21 ++++ .../Views/Dialogs/ExportDoneDialog.axaml.cs | 15 +++ YKanBan/Views/Dialogs/SearchErrorDialog.axaml | 21 ++++ .../Views/Dialogs/SearchErrorDialog.axaml.cs | 15 +++ YKanBan/Views/Dialogs/TagEditorDialog.axaml | 39 +++++++ .../Views/Dialogs/TagEditorDialog.axaml.cs | 25 +++++ YKanBan/Views/Tags/TagsView.axaml | 62 +++++++++++ YKanBan/Views/Tags/TagsView.axaml.cs | 76 +++++++++++++ YKanBan/Views/WorkspaceView.axaml | 7 +- YKanBan/Views/WorkspaceView.axaml.cs | 68 ++++++++++++ YKanBan/YKanBan.csproj | 2 +- 28 files changed, 1022 insertions(+), 24 deletions(-) create mode 100644 YKanBan/ViewModels/Dialogs/DeleteTagConfirmViewModel.cs create mode 100644 YKanBan/ViewModels/Dialogs/ExportDoneViewModel.cs create mode 100644 YKanBan/ViewModels/Dialogs/SearchErrorViewModel.cs create mode 100644 YKanBan/ViewModels/Dialogs/TagEditorViewModel.cs create mode 100644 YKanBan/ViewModels/Tags/TagItemViewModel.cs create mode 100644 YKanBan/ViewModels/Tags/TagsViewModel.cs create mode 100644 YKanBan/Views/Dialogs/DeleteTagConfirmDialog.axaml create mode 100644 YKanBan/Views/Dialogs/DeleteTagConfirmDialog.axaml.cs create mode 100644 YKanBan/Views/Dialogs/ExportDoneDialog.axaml create mode 100644 YKanBan/Views/Dialogs/ExportDoneDialog.axaml.cs create mode 100644 YKanBan/Views/Dialogs/SearchErrorDialog.axaml create mode 100644 YKanBan/Views/Dialogs/SearchErrorDialog.axaml.cs create mode 100644 YKanBan/Views/Dialogs/TagEditorDialog.axaml create mode 100644 YKanBan/Views/Dialogs/TagEditorDialog.axaml.cs create mode 100644 YKanBan/Views/Tags/TagsView.axaml create mode 100644 YKanBan/Views/Tags/TagsView.axaml.cs diff --git a/YKanBan.Tests/Storage/Workspace/WorkspaceRepositoryTests.cs b/YKanBan.Tests/Storage/Workspace/WorkspaceRepositoryTests.cs index 0771973..d5d4bd4 100644 --- a/YKanBan.Tests/Storage/Workspace/WorkspaceRepositoryTests.cs +++ b/YKanBan.Tests/Storage/Workspace/WorkspaceRepositoryTests.cs @@ -1,5 +1,6 @@ using Microsoft.Data.Sqlite; using YKanBan.Models; +using YKanBan.Search; using YKanBan.Storage.Workspace; using YKanBan.Tests.TestUtilities; @@ -257,6 +258,17 @@ public class WorkspaceRepositoryTests fixture.Repository.AddTag("bug", RgbColor.Parse("#123456"), "")); } + [TestMethod] + public void GetCardIdsMatchingRunsTheCompiledSearch() + { + using var fixture = new RepositoryFixture(); + CompiledSearch compiled = SearchSqlCompiler.Compile(new SearchNode.FreeText("T")); + + IReadOnlyCollection ids = fixture.Repository.GetCardIdsMatching(compiled); + + CollectionAssert.AreEquivalent(new[] { fixture.CardId }, ids.ToList()); + } + [TestMethod] public void GetTagsWithUsageCountsAssignments() { diff --git a/YKanBan/App.axaml b/YKanBan/App.axaml index 93c3bc9..a5065d1 100644 --- a/YKanBan/App.axaml +++ b/YKanBan/App.axaml @@ -12,6 +12,7 @@ + diff --git a/YKanBan/Assets/Locales/Resources.resx b/YKanBan/Assets/Locales/Resources.resx index 0ee6a7b..da721b8 100644 --- a/YKanBan/Assets/Locales/Resources.resx +++ b/YKanBan/Assets/Locales/Resources.resx @@ -28,6 +28,9 @@ Delete + + Edit + Export @@ -70,6 +73,18 @@ No columns yet. + + Board is filtered + + + Board is not filtered + + + Invalid search + + + The query expression is invalid. Details: {0} + ID Ascending @@ -166,6 +181,39 @@ There are no more tags to add. + + Add Tag + + + No tags yet. + + + {0} cards + + + New Tag + + + Edit Tag + + + Name + + + Color + + + Description + + + The name is empty or already used. + + + Delete Tag + + + Delete tag "{0}"? It will be removed from {1} card(s). + Move Card @@ -391,4 +439,10 @@ | Name | Color | Description | Usage | + + Export Complete + + + Exported to: {0} + diff --git a/YKanBan/Assets/Locales/Resources.zh-Hans.resx b/YKanBan/Assets/Locales/Resources.zh-Hans.resx index 804e2bc..a0fa746 100644 --- a/YKanBan/Assets/Locales/Resources.zh-Hans.resx +++ b/YKanBan/Assets/Locales/Resources.zh-Hans.resx @@ -28,6 +28,9 @@ 删除 + + 编辑 + 导出 @@ -70,6 +73,18 @@ 还没有列。 + + 看板处于过滤状态 + + + 看板未处于过滤状态 + + + 搜索表达式有误 + + + 查询表达式有误,详情:{0} + ID 正序 @@ -166,6 +181,39 @@ 没有可添加的标签。 + + 添加标签 + + + 还没有标签。 + + + {0} 张卡片 + + + 新建标签 + + + 编辑标签 + + + 名称 + + + 颜色 + + + 描述 + + + 名称为空或已被使用。 + + + 删除标签 + + + 删除标签“{0}”?将从 {1} 张卡片中移除。 + 移动卡片 @@ -391,4 +439,10 @@ | 名称 | 颜色 | 描述 | 使用数 | + + 导出完成 + + + 已导出到:{0} + diff --git a/YKanBan/Storage/Workspace/WorkspaceRepository.cs b/YKanBan/Storage/Workspace/WorkspaceRepository.cs index 479296c..c33de3e 100644 --- a/YKanBan/Storage/Workspace/WorkspaceRepository.cs +++ b/YKanBan/Storage/Workspace/WorkspaceRepository.cs @@ -1,5 +1,6 @@ using Microsoft.Data.Sqlite; using YKanBan.Models; +using YKanBan.Search; namespace YKanBan.Storage.Workspace; @@ -172,6 +173,28 @@ public sealed class WorkspaceRepository : IDisposable return cards; } + /// + /// Returns the ids of the cards matching a compiled search predicate. + /// + /// The compiled search predicate and its parameters. + /// The matching card ids. + public IReadOnlyCollection GetCardIdsMatching(CompiledSearch search) + { + using var command = CreateCommand($"SELECT c.id FROM cards c WHERE {search.Predicate};"); + foreach ((string name, object value) in search.Parameters) + { + command.Parameters.AddWithValue(name, value); + } + using var reader = command.ExecuteReader(); + + var ids = new HashSet(); + while (reader.Read()) + { + ids.Add(reader.GetInt64(0)); + } + return ids; + } + /// /// Returns the card with the given id, with its tags loaded. /// diff --git a/YKanBan/ViewModels/Board/BoardViewModel.cs b/YKanBan/ViewModels/Board/BoardViewModel.cs index d71eb45..88b69db 100644 --- a/YKanBan/ViewModels/Board/BoardViewModel.cs +++ b/YKanBan/ViewModels/Board/BoardViewModel.cs @@ -2,6 +2,7 @@ using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using YKanBan.Models; +using YKanBan.Search; using YKanBan.Services; using YKanBan.Storage.AppData; using YKanBan.Storage.Workspace; @@ -19,6 +20,9 @@ public sealed partial class BoardViewModel : ViewModelBase private readonly AppServices _services; private readonly WorkspaceRepository _repository; + // The active search result filter, or null when the whole board is shown. + private IReadOnlyCollection? _matchedCardIds; + /// /// Initializes the board, loading its current content. /// @@ -66,6 +70,21 @@ public sealed partial class BoardViewModel : ViewModelBase [ObservableProperty] private double columnWidth; + /// + /// Gets a value indicating whether a search filter is currently applied. + /// + public bool IsFiltering => _matchedCardIds is not null; + + /// + /// Gets the localized filter status text shown as the status icon tooltip. + /// + public string FilterStatusText => IsFiltering ? Resources.Board_FilterActive : Resources.Board_FilterInactive; + + /// + /// Gets the status icon opacity: full while filtering, dimmed otherwise. + /// + public double FilterIconOpacity => IsFiltering ? 1.0 : 0.4; + /// /// Gets a value indicating whether the board has any columns. /// @@ -99,8 +118,8 @@ public sealed partial class BoardViewModel : ViewModelBase /// Gets the interaction that opens the move-card dialog. public Interaction MoveCard { get; } = new(); - /// Gets the interaction that runs the current search. - public Interaction RunSearch { get; } = new(); + /// Gets the interaction that shows the search error dialog, carrying the English parser detail. + public Interaction ShowSearchError { get; } = new(); #endregion @@ -113,16 +132,64 @@ public sealed partial class BoardViewModel : ViewModelBase private Task AddNewColumnAsync() => AddColumn.Handle(Unit.Value); /// - /// Runs the current search (wired up with the search feature). + /// Runs the current search. A blank query shows everything; an invalid + /// expression opens the error dialog and keeps the previous results and text. /// [RelayCommand] - private Task SearchAsync() => RunSearch.Handle(Unit.Value); + private async Task SearchAsync() + { + if (string.IsNullOrWhiteSpace(SearchText)) + { + ApplyFilter(null); + return; + } + + try + { + SearchNode? query = SearchQueryParser.Parse(SearchText); + if (query is null) + { + ApplyFilter(null); + return; + } + + CompiledSearch compiled = SearchSqlCompiler.Compile(query); + ApplyFilter(_repository.GetCardIdsMatching(compiled)); + } + catch (SearchSyntaxException exception) + { + // The error dialog carries the parser's non-localized English detail. + await ShowSearchError.Handle(exception.Message); + } + } /// - /// Clears the search box. + /// Clears the search box and restores the unfiltered board. /// [RelayCommand] - private void ClearSearch() => SearchText = string.Empty; + private void ClearSearch() + { + SearchText = string.Empty; + ApplyFilter(null); + } + + /// + /// Applies a new result filter (null clears it) and rebuilds the board when it changed. + /// + /// The new filter, or for no filter. + private void ApplyFilter(IReadOnlyCollection? matchedIds) + { + if (_matchedCardIds is null && matchedIds is null) + { + return; + } + + _matchedCardIds = matchedIds; + OnPropertyChanged(nameof(IsFiltering)); + OnPropertyChanged(nameof(FilterStatusText)); + OnPropertyChanged(nameof(FilterIconOpacity)); + Reload(); + } #endregion @@ -182,6 +249,12 @@ public sealed partial class BoardViewModel : ViewModelBase IReadOnlyList columns = _repository.GetColumns(); IReadOnlyList cards = _repository.GetCards(); + // An active search hides non-matching cards while every column stays visible. + if (_matchedCardIds is not null) + { + cards = cards.Where(card => _matchedCardIds.Contains(card.Id)).ToList(); + } + // Group cards by column once so each column can be built independently. Dictionary> cardsByColumn = cards .GroupBy(card => card.ColumnId) diff --git a/YKanBan/ViewModels/Dialogs/DeleteTagConfirmViewModel.cs b/YKanBan/ViewModels/Dialogs/DeleteTagConfirmViewModel.cs new file mode 100644 index 0000000..6047f34 --- /dev/null +++ b/YKanBan/ViewModels/Dialogs/DeleteTagConfirmViewModel.cs @@ -0,0 +1,29 @@ +using System.Globalization; + +namespace YKanBan.ViewModels.Dialogs; + +/// +/// The confirmation shown before deleting a tag, naming the tag and how many +/// cards it will be removed from. +/// +public sealed class DeleteTagConfirmViewModel : ViewModelBase +{ + /// + /// Initializes the confirmation. + /// + /// The name of the tag to delete. + /// The number of cards using the tag. + public DeleteTagConfirmViewModel(string tagName, long usageCount) + { + Message = string.Format( + Resources.Culture ?? CultureInfo.CurrentCulture, + Resources.DeleteTag_Confirm_Message, + tagName, + usageCount); + } + + /// + /// Gets the localized confirmation message. + /// + public string Message { get; } +} diff --git a/YKanBan/ViewModels/Dialogs/ExportDoneViewModel.cs b/YKanBan/ViewModels/Dialogs/ExportDoneViewModel.cs new file mode 100644 index 0000000..53ef6f7 --- /dev/null +++ b/YKanBan/ViewModels/Dialogs/ExportDoneViewModel.cs @@ -0,0 +1,25 @@ +using System.Globalization; + +namespace YKanBan.ViewModels.Dialogs; + +/// +/// The confirmation dialog shown after a successful Markdown export, naming the +/// written file. +/// +public sealed class ExportDoneViewModel : ViewModelBase +{ + /// + /// Initializes the dialog with the exported file path. + /// + /// The path the export was written to. + public ExportDoneViewModel(string filePath) + { + Message = string.Format( + Resources.Culture ?? CultureInfo.CurrentCulture, Resources.Export_Done_Message, filePath); + } + + /// + /// Gets the localized confirmation message. + /// + public string Message { get; } +} diff --git a/YKanBan/ViewModels/Dialogs/SearchErrorViewModel.cs b/YKanBan/ViewModels/Dialogs/SearchErrorViewModel.cs new file mode 100644 index 0000000..113bcd9 --- /dev/null +++ b/YKanBan/ViewModels/Dialogs/SearchErrorViewModel.cs @@ -0,0 +1,27 @@ +using System.Globalization; + +namespace YKanBan.ViewModels.Dialogs; + +/// +/// The pure-information dialog shown when the search expression is invalid. It +/// wraps the parser's non-localized English detail in the localized message. +/// +public sealed class SearchErrorViewModel : ViewModelBase +{ + /// + /// Initializes the dialog with the parser's English detail. + /// + /// The non-localized parser error description. + public SearchErrorViewModel(string detail) + { + Message = string.Format( + Resources.Culture ?? CultureInfo.CurrentCulture, + Resources.Search_Error_Message, + detail); + } + + /// + /// Gets the localized error message. + /// + public string Message { get; } +} diff --git a/YKanBan/ViewModels/Dialogs/TagEditorViewModel.cs b/YKanBan/ViewModels/Dialogs/TagEditorViewModel.cs new file mode 100644 index 0000000..9538d82 --- /dev/null +++ b/YKanBan/ViewModels/Dialogs/TagEditorViewModel.cs @@ -0,0 +1,97 @@ +using Avalonia.Media; +using CommunityToolkit.Mvvm.ComponentModel; +using Microsoft.Data.Sqlite; +using YKanBan.Models; +using YKanBan.Storage.Workspace; + +namespace YKanBan.ViewModels.Dialogs; + +/// +/// The add/edit tag dialog: a name, a color chosen with a color picker and an +/// optional description. The write happens when the dialog is accepted; a +/// duplicate or empty name keeps the dialog open with an inline error. +/// +public sealed partial class TagEditorViewModel : ViewModelBase +{ + private readonly WorkspaceRepository _repository; + private readonly TagModel? _existing; + + /// + /// Initializes the dialog for a new or existing tag. + /// + /// The workspace repository. + /// The tag to edit, or to add one. + public TagEditorViewModel(WorkspaceRepository repository, TagModel? existing) + { + _repository = repository; + _existing = existing; + name = existing?.Name ?? string.Empty; + description = existing?.Description ?? string.Empty; + color = existing is not null + ? Color.FromRgb(existing.Color.R, existing.Color.G, existing.Color.B) + : Colors.Gray; + } + + /// + /// Gets a value indicating whether this dialog creates a new tag. + /// + public bool IsNew => _existing is null; + + /// + /// Gets the localized window title. + /// + public string WindowTitle => IsNew ? Resources.TagEditor_AddTitle : Resources.TagEditor_EditTitle; + + /// + /// Gets or sets the tag name. + /// + [ObservableProperty] + private string name; + + /// + /// Gets or sets the tag color. + /// + [ObservableProperty] + private Color color; + + /// + /// Gets or sets the tag description. + /// + [ObservableProperty] + private string description; + + /// + /// Gets or sets the inline error shown when the name is rejected. + /// + [ObservableProperty] + private string? errorMessage; + + /// + /// Writes the tag when accepted. + /// + /// when the dialog may close. + public bool TryAccept() + { + ErrorMessage = null; + var rgb = new RgbColor(Color.R, Color.G, Color.B); + + try + { + if (_existing is null) + { + _repository.AddTag(Name, rgb, Description); + } + else + { + _repository.UpdateTag(_existing.Id, Name, rgb, Description); + } + + return true; + } + catch (SqliteException) + { + ErrorMessage = Resources.TagEditor_InvalidName; + return false; + } + } +} diff --git a/YKanBan/ViewModels/Tags/TagItemViewModel.cs b/YKanBan/ViewModels/Tags/TagItemViewModel.cs new file mode 100644 index 0000000..8df4930 --- /dev/null +++ b/YKanBan/ViewModels/Tags/TagItemViewModel.cs @@ -0,0 +1,78 @@ +using System.Globalization; +using CommunityToolkit.Mvvm.Input; +using YKanBan.Models; +using YKanBan.ViewModels.Board; + +namespace YKanBan.ViewModels.Tags; + +/// +/// One row of the tag manager: a color swatch, the name, the description and the +/// usage count, plus edit and delete actions. +/// +public sealed partial class TagItemViewModel +{ + private readonly TagsViewModel _owner; + + /// + /// Initializes the row. + /// + /// The owning tag manager. + /// The tag model. + /// The number of cards using the tag. + public TagItemViewModel(TagsViewModel owner, TagModel tag, long usageCount) + { + _owner = owner; + Tag = tag; + Badge = new TagBadgeViewModel(tag); + UsageCount = usageCount; + UsageText = string.Format( + Resources.Culture ?? CultureInfo.CurrentCulture, Resources.Tags_Usage, usageCount); + } + + /// + /// Gets the underlying tag model. + /// + public TagModel Tag { get; } + + /// + /// Gets the colored badge for the swatch and name. + /// + public TagBadgeViewModel Badge { get; } + + /// + /// Gets the tag name. + /// + public string Name => Tag.Name; + + /// + /// Gets the tag description. + /// + public string Description => Tag.Description; + + /// + /// Gets a value indicating whether the tag has a description. + /// + public bool HasDescription => Tag.Description.Length > 0; + + /// + /// Gets the number of cards using the tag. + /// + public long UsageCount { get; } + + /// + /// Gets the localized usage count. + /// + public string UsageText { get; } + + /// + /// Asks the manager to edit this tag. + /// + [RelayCommand] + private void Edit() => _ = _owner.RequestEditAsync(this); + + /// + /// Asks the manager to delete this tag. + /// + [RelayCommand] + private void Delete() => _ = _owner.RequestDeleteAsync(this); +} diff --git a/YKanBan/ViewModels/Tags/TagsViewModel.cs b/YKanBan/ViewModels/Tags/TagsViewModel.cs new file mode 100644 index 0000000..cff3070 --- /dev/null +++ b/YKanBan/ViewModels/Tags/TagsViewModel.cs @@ -0,0 +1,102 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.Input; +using YKanBan.Models; +using YKanBan.Storage.Workspace; + +namespace YKanBan.ViewModels.Tags; + +/// +/// The tags tab: a manager for the workspace's tags (create, rename, recolor, +/// re-describe and delete), showing each tag's color, name, description and +/// usage count. Tag changes also refresh the board. +/// +public sealed partial class TagsViewModel : ViewModelBase +{ + private readonly WorkspaceRepository _repository; + private readonly Action _boardChanged; + + /// + /// Initializes the manager, loading the current tags. + /// + /// The workspace repository. + /// Invoked after a tag change so the board refreshes. + public TagsViewModel(WorkspaceRepository repository, Action boardChanged) + { + _repository = repository; + _boardChanged = boardChanged; + Reload(); + } + + /// + /// Gets the workspace repository used for tag access. + /// + public WorkspaceRepository Repository => _repository; + + /// + /// Gets the tags in id order. + /// + public ObservableCollection Tags { get; } = []; + + /// + /// Gets a value indicating whether the workspace has any tags. + /// + public bool HasTags => Tags.Count > 0; + + /// + /// Gets a value indicating whether the workspace has no tags. + /// + public bool HasNoTags => Tags.Count == 0; + + /// Gets the interaction that opens the new-tag editor. + public Interaction AddTag { get; } = new(); + + /// Gets the interaction that opens the tag editor. + public Interaction EditTag { get; } = new(); + + /// Gets the interaction that confirms and deletes a tag. + public Interaction DeleteTag { get; } = new(); + + /// + /// Opens the new-tag editor. + /// + [RelayCommand] + private Task AddNewTagAsync() => AddTag.Handle(Unit.Value); + + /// + /// Requests the editor for a tag. + /// + /// The tag to edit. + /// A task completing after the editor closes. + public Task RequestEditAsync(TagItemViewModel item) => EditTag.Handle(item); + + /// + /// Requests deletion of a tag. + /// + /// The tag to delete. + /// A task completing after confirmation. + public Task RequestDeleteAsync(TagItemViewModel item) => DeleteTag.Handle(item); + + /// + /// Reloads the tag list from the repository. + /// + public void Reload() + { + Tags.Clear(); + foreach ((TagModel tag, long usage) in _repository.GetTagsWithUsage()) + { + Tags.Add(new TagItemViewModel(this, tag, usage)); + } + + OnPropertyChanged(nameof(HasTags)); + OnPropertyChanged(nameof(HasNoTags)); + } + + /// + /// Reloads the tags and refreshes the board after a tag change. + /// + public void RefreshAfterChange() + { + Reload(); + _boardChanged(); + } +} diff --git a/YKanBan/ViewModels/WorkspaceViewModel.cs b/YKanBan/ViewModels/WorkspaceViewModel.cs index 5dc58ea..2fcadab 100644 --- a/YKanBan/ViewModels/WorkspaceViewModel.cs +++ b/YKanBan/ViewModels/WorkspaceViewModel.cs @@ -2,6 +2,7 @@ using CommunityToolkit.Mvvm.Input; using YKanBan.Services; using YKanBan.Storage.Workspace; using YKanBan.ViewModels.Board; +using YKanBan.ViewModels.Tags; namespace YKanBan.ViewModels; @@ -32,7 +33,10 @@ public sealed partial class WorkspaceViewModel : ViewModelBase WorkspacePath = session.FolderPath; _showSettings = showSettings; _showAbout = showAbout; + + // Both tabs share the same repository; tag changes refresh the board. Board = new BoardViewModel(services, session); + Tags = new TagsViewModel(session.Repository, Board.Reload); } /// @@ -55,6 +59,16 @@ public sealed partial class WorkspaceViewModel : ViewModelBase /// public BoardViewModel Board { get; } + /// + /// Gets the tags tab view model. + /// + public TagsViewModel Tags { get; } + + /// + /// Gets the interaction that runs the export flow. + /// + public Interaction Export { get; } = new(); + /// /// Opens the settings dialog through the registered interaction. /// @@ -68,11 +82,8 @@ public sealed partial class WorkspaceViewModel : ViewModelBase private async Task OpenAboutAsync() => await _showAbout.Handle(Unit.Value); /// - /// Raised by the export button; the export flow is wired up with the export - /// feature. + /// Runs the export flow through the registered interaction. /// [RelayCommand] - private static void Export() - { - } + private async Task RunExportAsync() => await Export.Handle(Unit.Value); } diff --git a/YKanBan/Views/Board/BoardView.axaml b/YKanBan/Views/Board/BoardView.axaml index b0546bb..785abee 100644 --- a/YKanBan/Views/Board/BoardView.axaml +++ b/YKanBan/Views/Board/BoardView.axaml @@ -13,28 +13,40 @@ - - + + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/YKanBan/Views/Tags/TagsView.axaml.cs b/YKanBan/Views/Tags/TagsView.axaml.cs new file mode 100644 index 0000000..ca7cc76 --- /dev/null +++ b/YKanBan/Views/Tags/TagsView.axaml.cs @@ -0,0 +1,76 @@ +using Avalonia.Controls; +using YKanBan.ViewModels.Dialogs; +using YKanBan.ViewModels.Tags; +using YKanBan.Views.Dialogs; + +namespace YKanBan.Views.Tags; + +public partial class TagsView : UserControl +{ + private bool _registered; + + public TagsView() + { + InitializeComponent(); + } + + protected override void OnDataContextChanged(EventArgs e) + { + base.OnDataContextChanged(e); + if (DataContext is TagsViewModel tags && !_registered) + { + _registered = true; + tags.AddTag.RegisterHandler(_ => EditTagAsync(tags, null)); + tags.EditTag.RegisterHandler(item => EditTagAsync(tags, item)); + tags.DeleteTag.RegisterHandler(item => DeleteTagAsync(tags, item)); + } + } + + /// Gets the window that owns this view, used as the dialog owner. + private Window? OwningWindow => TopLevel.GetTopLevel(this) as Window; + + /// Shows the add/edit tag dialog and refreshes on success. + private async Task EditTagAsync(TagsViewModel tags, TagItemViewModel? item) + { + if (OwningWindow is not Window owner) + { + return; + } + + var dialog = new TagEditorDialog + { + DataContext = new TagEditorViewModel(tags.Repository, item?.Tag), + }; + if (await dialog.ShowDialog(owner)) + { + tags.RefreshAfterChange(); + } + } + + /// Confirms and deletes a tag, then refreshes. + private async Task DeleteTagAsync(TagsViewModel tags, TagItemViewModel item) + { + if (OwningWindow is not Window owner) + { + return; + } + + bool confirmed = true; + if (App.Services?.Config.Confirmations.DeleteTag == true) + { + var dialog = new DeleteTagConfirmDialog + { + DataContext = new DeleteTagConfirmViewModel(item.Name, item.UsageCount), + }; + confirmed = await dialog.ShowDialog(owner); + } + + if (!confirmed) + { + return; + } + + tags.Repository.DeleteTag(item.Tag.Id); + tags.RefreshAfterChange(); + } +} diff --git a/YKanBan/Views/WorkspaceView.axaml b/YKanBan/Views/WorkspaceView.axaml index b9a6a57..d75e811 100644 --- a/YKanBan/Views/WorkspaceView.axaml +++ b/YKanBan/Views/WorkspaceView.axaml @@ -4,6 +4,7 @@ xmlns:lang="using:YKanBan" xmlns:icons="using:IconPacks.Avalonia.Lucide" xmlns:board="using:YKanBan.Views.Board" + xmlns:tags="using:YKanBan.Views.Tags" 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="640" @@ -27,7 +28,7 @@ -