< Summary

Information
Class: Chronicis.Client.ViewModels.ArticleDetailViewModel
Assembly: Chronicis.Client
File(s): /home/runner/work/chronicis/chronicis/src/Chronicis.Client/ViewModels/ArticleDetailViewModel.cs
Line coverage
100%
Covered lines: 71
Uncovered lines: 0
Coverable lines: 71
Total lines: 619
Line coverage: 100%
Branch coverage
100%
Covered branches: 16
Total branches: 16
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
get_Article()100%11100%
set_Article(...)100%11100%
get_Breadcrumbs()100%11100%
set_Breadcrumbs(...)100%11100%
get_EditTitle()100%11100%
set_EditTitle(...)100%22100%
get_EditBody()100%11100%
set_EditBody(...)100%22100%
get_IsLoading()100%11100%
set_IsLoading(...)100%11100%
get_IsSaving()100%11100%
set_IsSaving(...)100%11100%
get_IsAutoLinking()100%11100%
set_IsAutoLinking(...)100%11100%
get_IsCreatingChild()100%11100%
set_IsCreatingChild(...)100%11100%
get_HasUnsavedChanges()100%11100%
set_HasUnsavedChanges(...)100%11100%
get_IsSummaryExpanded()100%11100%
set_IsSummaryExpanded(...)100%11100%
get_LastSaveTime()100%11100%
set_LastSaveTime(...)100%11100%
ClearArticle()100%11100%
RefreshBreadcrumbs()100%66100%
GetDeleteConfirmationMessage()100%66100%

File(s)

/home/runner/work/chronicis/chronicis/src/Chronicis.Client/ViewModels/ArticleDetailViewModel.cs

#LineLine coverage
 1using Chronicis.Client.Abstractions;
 2using Chronicis.Client.Services;
 3using Chronicis.Shared.DTOs;
 4using Chronicis.Shared.Extensions;
 5using Chronicis.Shared.Utilities;
 6using MudBlazor;
 7
 8namespace Chronicis.Client.ViewModels;
 9
 10/// <summary>
 11/// ViewModel for the ArticleDetail component.
 12/// Owns pure C# business logic: loading, saving, deleting, and creating articles.
 13/// JS-interop concerns (TipTap editor lifecycle, autocomplete cursor position,
 14/// external link preview, DotNetObjectReference) remain in the component.
 15/// </summary>
 16public sealed class ArticleDetailViewModel : ViewModelBase
 17{
 18    private readonly IArticleApiService _articleApi;
 19    private readonly ILinkApiService _linkApi;
 20    private readonly ITreeStateService _treeState;
 21    private readonly IBreadcrumbService _breadcrumbService;
 22    private readonly IAppContextService _appContext;
 23    private readonly IArticleCacheService _articleCache;
 24    private readonly IAppNavigator _navigator;
 25    private readonly IUserNotifier _notifier;
 26    private readonly IPageTitleService _titleService;
 27    private readonly ILogger<ArticleDetailViewModel> _logger;
 28
 29    private ArticleDto? _article;
 30    private List<BreadcrumbItem>? _breadcrumbs;
 4531    private string _editTitle = string.Empty;
 4532    private string _editBody = string.Empty;
 33    private bool _isLoading;
 34    private bool _isSaving;
 35    private bool _isAutoLinking;
 36    private bool _isCreatingChild;
 37    private bool _hasUnsavedChanges;
 38    private bool _isSummaryExpanded;
 4539    private string _lastSaveTime = "just now";
 40
 4541    public ArticleDetailViewModel(
 4542        IArticleApiService articleApi,
 4543        ILinkApiService linkApi,
 4544        ITreeStateService treeState,
 4545        IBreadcrumbService breadcrumbService,
 4546        IAppContextService appContext,
 4547        IArticleCacheService articleCache,
 4548        IAppNavigator navigator,
 4549        IUserNotifier notifier,
 4550        IPageTitleService titleService,
 4551        ILogger<ArticleDetailViewModel> logger)
 52    {
 4553        _articleApi = articleApi;
 4554        _linkApi = linkApi;
 4555        _treeState = treeState;
 4556        _breadcrumbService = breadcrumbService;
 4557        _appContext = appContext;
 4558        _articleCache = articleCache;
 4559        _navigator = navigator;
 4560        _notifier = notifier;
 4561        _titleService = titleService;
 4562        _logger = logger;
 4563    }
 64
 65    // ---------------------------------------------------------------------------
 66    // Properties
 67    // ---------------------------------------------------------------------------
 68
 69    public ArticleDto? Article
 70    {
 871        get => _article;
 3672        private set => SetField(ref _article, value);
 73    }
 74
 75    public List<BreadcrumbItem>? Breadcrumbs
 76    {
 577        get => _breadcrumbs;
 3478        private set => SetField(ref _breadcrumbs, value);
 79    }
 80
 81    public string EditTitle
 82    {
 983        get => _editTitle;
 84        set
 85        {
 3686            if (SetField(ref _editTitle, value))
 3587                HasUnsavedChanges = true;
 3688        }
 89    }
 90
 91    public string EditBody
 92    {
 293        get => _editBody;
 94        set
 95        {
 3596            if (SetField(ref _editBody, value))
 3497                HasUnsavedChanges = true;
 3598        }
 99    }
 100
 101    public bool IsLoading
 102    {
 3103        get => _isLoading;
 62104        private set => SetField(ref _isLoading, value);
 105    }
 106
 107    public bool IsSaving
 108    {
 12109        get => _isSaving;
 14110        private set => SetField(ref _isSaving, value);
 111    }
 112
 113    public bool IsAutoLinking
 114    {
 6115        get => _isAutoLinking;
 8116        private set => SetField(ref _isAutoLinking, value);
 117    }
 118
 119    public bool IsCreatingChild
 120    {
 3121        get => _isCreatingChild;
 2122        private set => SetField(ref _isCreatingChild, value);
 123    }
 124
 125    public bool HasUnsavedChanges
 126    {
 7127        get => _hasUnsavedChanges;
 110128        set => SetField(ref _hasUnsavedChanges, value);
 129    }
 130
 131    public bool IsSummaryExpanded
 132    {
 1133        get => _isSummaryExpanded;
 1134        set => SetField(ref _isSummaryExpanded, value);
 135    }
 136
 137    public string LastSaveTime
 138    {
 3139        get => _lastSaveTime;
 6140        private set => SetField(ref _lastSaveTime, value);
 141    }
 142
 143    // ---------------------------------------------------------------------------
 144    // Loading
 145    // ---------------------------------------------------------------------------
 146
 147    /// <summary>
 148    /// Hydrates VM state from an already-fetched article.
 149    /// </summary>
 150    public async Task HydrateArticleAsync(ArticleDto article)
 151    {
 152        Article = article;
 153        EditTitle = article.Title ?? string.Empty;
 154        EditBody = article.Body ?? string.Empty;
 155        HasUnsavedChanges = false;
 156
 157        _articleCache.CacheArticle(article);
 158        RefreshBreadcrumbs();
 159
 160        await _titleService.SetTitleAsync(
 161            string.IsNullOrEmpty(article.Title) ? "Untitled" : article.Title);
 162    }
 163
 164    /// <summary>
 165    /// Loads an article by ID and populates all VM state.
 166    /// Returns true if the article was loaded successfully.
 167    /// </summary>
 168    public async Task<bool> LoadArticleAsync(Guid articleId)
 169    {
 170        IsLoading = true;
 171
 172        try
 173        {
 174            var article = await _articleApi.GetArticleAsync(articleId);
 175            Article = article;
 176            EditTitle = article?.Title ?? string.Empty;
 177            EditBody = article?.Body ?? string.Empty;
 178            HasUnsavedChanges = false;
 179
 180            if (article != null)
 181                _articleCache.CacheArticle(article);
 182
 183            RefreshBreadcrumbs();
 184            await _titleService.SetTitleAsync(
 185                string.IsNullOrEmpty(article?.Title) ? "Untitled" : article.Title);
 186
 187            return article != null;
 188        }
 189        catch (Exception ex)
 190        {
 191            _logger.LogErrorSanitized(ex, "Failed to load article {ArticleId}", articleId);
 192            _notifier.Error($"Failed to load article: {ex.Message}");
 193            return false;
 194        }
 195        finally
 196        {
 197            IsLoading = false;
 198        }
 199    }
 200
 201    /// <summary>Clears article state (called when tree selection is cleared).</summary>
 202    public void ClearArticle()
 203    {
 1204        Article = null;
 1205        Breadcrumbs = null;
 1206        EditTitle = string.Empty;
 1207        EditBody = string.Empty;
 1208        HasUnsavedChanges = false;
 1209        _ = _titleService.SetTitleAsync("Chronicis");
 1210    }
 211
 212    // ---------------------------------------------------------------------------
 213    // Save
 214    // ---------------------------------------------------------------------------
 215
 216    /// <summary>
 217    /// Saves the current article. The caller must pass the current editor body
 218    /// (retrieved from the JS TipTap editor) as <paramref name="currentBody"/>.
 219    /// </summary>
 220    public async Task<SaveArticleResult> SaveArticleAsync(string currentBody)
 221    {
 222        if (_article == null || IsSaving)
 223            return SaveArticleResult.Skipped;
 224
 225        // Sync editor body into VM state before saving
 226        _editBody = currentBody;
 227        IsSaving = true;
 228
 229        try
 230        {
 231            var originalTitle = _article.Title;
 232            var newTitle = EditTitle.Trim();
 233            var titleChanged = originalTitle != newTitle;
 234            string? newSlug = null;
 235
 236            if (titleChanged)
 237            {
 238                var suggestedSlug = SlugGenerator.GenerateSlug(newTitle);
 239                if (suggestedSlug != _article.Slug)
 240                    newSlug = suggestedSlug;
 241            }
 242
 243            var updateDto = new ArticleUpdateDto
 244            {
 245                Title = newTitle,
 246                Slug = newSlug,
 247                Body = _editBody,
 248                EffectiveDate = _article.EffectiveDate,
 249                IconEmoji = _article.IconEmoji
 250            };
 251
 252            await _articleApi.UpdateArticleAsync(_article.Id, updateDto);
 253            _articleCache.InvalidateCache();
 254
 255            _article.Title = newTitle;
 256            _article.Body = _editBody;
 257            _article.ModifiedAt = DateTime.Now;
 258            HasUnsavedChanges = false;
 259            LastSaveTime = "just now";
 260
 261            bool slugChanged = newSlug != null;
 262
 263            if (titleChanged || slugChanged)
 264            {
 265                await _titleService.SetTitleAsync(newTitle);
 266                _treeState.UpdateNodeDisplay(_article.Id, newTitle, _article.IconEmoji);
 267            }
 268
 269            if (slugChanged)
 270            {
 271                _treeState.ExpandPathToAndSelect(_article.Id);
 272                var refreshed = await _articleApi.GetArticleAsync(_article.Id);
 273                Article = refreshed;
 274
 275                if (refreshed?.Breadcrumbs != null && refreshed.Breadcrumbs.Any())
 276                {
 277                    var path = _breadcrumbService.BuildArticleUrl(refreshed.Breadcrumbs);
 278                    return SaveArticleResult.NavigateTo(path);
 279                }
 280            }
 281
 282            return SaveArticleResult.Saved;
 283        }
 284        catch (Exception ex)
 285        {
 286            _logger.LogErrorSanitized(ex, "Failed to save article {ArticleId}", _article?.Id);
 287            _notifier.Error($"Failed to save: {ex.Message}");
 288            return SaveArticleResult.Failed;
 289        }
 290        finally
 291        {
 292            IsSaving = false;
 293        }
 294    }
 295
 296    // ---------------------------------------------------------------------------
 297    // Delete
 298    // ---------------------------------------------------------------------------
 299
 300    /// <summary>
 301    /// Deletes the current article. Returns true on success.
 302    /// Caller is responsible for the confirmation dialog (JS or MudBlazor).
 303    /// </summary>
 304    public async Task<bool> DeleteArticleAsync()
 305    {
 306        if (_article == null)
 307            return false;
 308
 309        try
 310        {
 311            var success = await _articleApi.DeleteArticleAsync(_article.Id);
 312            if (success)
 313            {
 314                _articleCache.InvalidateCache();
 315                _notifier.Success("Article deleted successfully");
 316                await _treeState.RefreshAsync();
 317                Article = null;
 318                return true;
 319            }
 320            else
 321            {
 322                _notifier.Error("Failed to delete article. You may not have permission.");
 323                return false;
 324            }
 325        }
 326        catch (Exception ex)
 327        {
 328            _logger.LogErrorSanitized(ex, "Failed to delete article {ArticleId}", _article?.Id);
 329            _notifier.Error($"Failed to delete: {ex.Message}");
 330            return false;
 331        }
 332    }
 333
 334    // ---------------------------------------------------------------------------
 335    // Icon
 336    // ---------------------------------------------------------------------------
 337
 338    /// <summary>
 339    /// Saves an icon change immediately without a full save cycle.
 340    /// </summary>
 341    public async Task HandleIconChangedAsync(string? newIcon)
 342    {
 343        if (_article == null || IsSaving)
 344            return;
 345
 346        _article.IconEmoji = newIcon;
 347
 348        try
 349        {
 350            var updateDto = new ArticleUpdateDto
 351            {
 352                Title = _article.Title,
 353                Slug = _article.Slug,
 354                Body = _article.Body,
 355                EffectiveDate = _article.EffectiveDate,
 356                IconEmoji = newIcon
 357            };
 358
 359            await _articleApi.UpdateArticleAsync(_article.Id, updateDto);
 360            _treeState.UpdateNodeDisplay(_article.Id, _article.Title, newIcon);
 361        }
 362        catch (Exception ex)
 363        {
 364            _logger.LogErrorSanitized(ex, "Failed to save icon change for article {ArticleId}", _article?.Id);
 365            _notifier.Error($"Failed to save icon: {ex.Message}");
 366        }
 367    }
 368
 369    // ---------------------------------------------------------------------------
 370    // Article Creation
 371    // ---------------------------------------------------------------------------
 372
 373    /// <summary>
 374    /// Creates a root-level article in the current world.
 375    /// Returns the created article's slug for navigation, or null on failure.
 376    /// </summary>
 377    public async Task<string?> CreateRootArticleAsync()
 378    {
 379        var worldId = _appContext.CurrentWorldId;
 380        if (!worldId.HasValue || worldId == Guid.Empty)
 381        {
 382            _notifier.Warning("Please select a World first");
 383            return null;
 384        }
 385
 386        try
 387        {
 388            var createDto = new ArticleCreateDto
 389            {
 390                Title = "Untitled",
 391                Body = string.Empty,
 392                ParentId = null,
 393                EffectiveDate = DateTime.Now,
 394                WorldId = worldId.Value
 395            };
 396
 397            await _articleApi.CreateArticleAsync(createDto);
 398            await _treeState.RefreshAsync();
 399            _notifier.Success("Article created");
 400            return null; // root creation refreshes tree; no navigation needed
 401        }
 402        catch (Exception ex)
 403        {
 404            _logger.LogErrorSanitized(ex, "Failed to create root article");
 405            _notifier.Error($"Failed to create article: {ex.Message}");
 406            return null;
 407        }
 408    }
 409
 410    /// <summary>
 411    /// Creates a sibling article (same parent as current).
 412    /// Returns the navigation path on success, or null on failure.
 413    /// </summary>
 414    public async Task<string?> CreateSiblingArticleAsync()
 415    {
 416        if (_article == null)
 417            return null;
 418
 419        try
 420        {
 421            var createDto = new ArticleCreateDto
 422            {
 423                Title = string.Empty,
 424                Body = string.Empty,
 425                ParentId = _article.ParentId,
 426                EffectiveDate = DateTime.Now,
 427                WorldId = _article.WorldId,
 428                CampaignId = _article.CampaignId
 429            };
 430
 431            var created = await _articleApi.CreateArticleAsync(createDto);
 432            if (created == null)
 433            {
 434                _notifier.Error("Failed to create article");
 435                return null;
 436            }
 437
 438            await _treeState.RefreshAsync();
 439            _treeState.ExpandPathToAndSelect(created.Id);
 440
 441            var detail = await _articleApi.GetArticleDetailAsync(created.Id);
 442            _notifier.Success("New article created");
 443
 444            if (detail?.Breadcrumbs != null && detail.Breadcrumbs.Any())
 445                return _breadcrumbService.BuildArticleUrl(detail.Breadcrumbs);
 446
 447            return $"/article/{created.Slug}";
 448        }
 449        catch (Exception ex)
 450        {
 451            _logger.LogErrorSanitized(ex, "Failed to create sibling article");
 452            _notifier.Error($"Failed to create article: {ex.Message}");
 453            return null;
 454        }
 455    }
 456
 457    /// <summary>
 458    /// Creates a child article under the current article.
 459    /// Returns the navigation path on success, or null on failure.
 460    /// </summary>
 461    public async Task<string?> CreateChildArticleAsync()
 462    {
 463        if (_article == null || IsCreatingChild)
 464            return null;
 465
 466        IsCreatingChild = true;
 467
 468        try
 469        {
 470            var createDto = new ArticleCreateDto
 471            {
 472                Title = string.Empty,
 473                Body = string.Empty,
 474                ParentId = _article.Id,
 475                EffectiveDate = DateTime.Now,
 476                WorldId = _article.WorldId,
 477                CampaignId = _article.CampaignId
 478            };
 479
 480            var created = await _articleApi.CreateArticleAsync(createDto);
 481            if (created == null)
 482            {
 483                _notifier.Error("Failed to create article");
 484                return null;
 485            }
 486
 487            await _treeState.RefreshAsync();
 488            _treeState.ExpandPathToAndSelect(created.Id);
 489
 490            var detail = await _articleApi.GetArticleDetailAsync(created.Id);
 491            _notifier.Success("New article created");
 492
 493            if (detail?.Breadcrumbs != null && detail.Breadcrumbs.Any())
 494                return _breadcrumbService.BuildArticleUrl(detail.Breadcrumbs);
 495
 496            return $"/article/{created.Slug}";
 497        }
 498        catch (Exception ex)
 499        {
 500            _logger.LogErrorSanitized(ex, "Failed to create child article");
 501            _notifier.Error($"Failed to create article: {ex.Message}");
 502            return null;
 503        }
 504        finally
 505        {
 506            IsCreatingChild = false;
 507        }
 508    }
 509
 510    // ---------------------------------------------------------------------------
 511    // Auto-link
 512    // ---------------------------------------------------------------------------
 513
 514    /// <summary>
 515    /// Fetches auto-link suggestions for the current article and current body content.
 516    /// Returns the result for the caller to display a confirmation dialog,
 517    /// or null if nothing was found or an error occurred.
 518    /// </summary>
 519    public async Task<AutoLinkResponseDto?> FetchAutoLinkSuggestionsAsync(string currentBody)
 520    {
 521        if (_article == null || IsAutoLinking)
 522            return null;
 523
 524        IsAutoLinking = true;
 525
 526        try
 527        {
 528            var result = await _linkApi.AutoLinkAsync(_article.Id, currentBody);
 529
 530            if (result == null)
 531            {
 532                _notifier.Error("Failed to scan for links");
 533                return null;
 534            }
 535
 536            if (result.LinksFound == 0)
 537            {
 538                _notifier.Info("No linkable content found");
 539                return null;
 540            }
 541
 542            return result;
 543        }
 544        catch (Exception ex)
 545        {
 546            _logger.LogErrorSanitized(ex, "Error auto-linking article {ArticleId}", _article?.Id);
 547            _notifier.Error($"Failed to auto-link: {ex.Message}");
 548            return null;
 549        }
 550        finally
 551        {
 552            IsAutoLinking = false;
 553        }
 554    }
 555
 556    /// <summary>
 557    /// Called after the component has applied wiki links via JS.
 558    /// Syncs the updated body and triggers a save.
 559    /// </summary>
 560    public async Task CommitAutoLinkAsync(string updatedBody, int linksApplied)
 561    {
 562        _editBody = updatedBody;
 563        HasUnsavedChanges = true;
 564        await SaveArticleAsync(updatedBody);
 565        _notifier.Success($"Added {linksApplied} link(s)");
 566    }
 567
 568    // ---------------------------------------------------------------------------
 569    // Helpers
 570    // ---------------------------------------------------------------------------
 571
 572    private void RefreshBreadcrumbs()
 573    {
 33574        if (_article?.Breadcrumbs != null && _article.Breadcrumbs.Any())
 28575            Breadcrumbs = _breadcrumbService.ForArticle(_article.Breadcrumbs);
 576        else
 5577            Breadcrumbs = new List<BreadcrumbItem> { new("Dashboard", href: "/dashboard") };
 5578    }
 579
 580    /// <summary>Generates the delete confirmation message including child count warning.</summary>
 581    public string GetDeleteConfirmationMessage()
 582    {
 4583        if (_article == null)
 1584            return string.Empty;
 585
 3586        var message = $"Are you sure you want to delete '{_article.Title}'?";
 3587        if (_article.ChildCount > 0)
 588        {
 2589            var childText = _article.ChildCount == 1
 2590                ? "1 child article"
 2591                : $"{_article.ChildCount} child articles";
 2592            message = $"Are you sure you want to delete '{_article.Title}'?\n\n⚠️ WARNING: This will also delete {childT
 593        }
 594
 3595        return message + "\n\nThis action cannot be undone.";
 596    }
 597}
 598
 599/// <summary>
 600/// Discriminated union result for <see cref="ArticleDetailViewModel.SaveArticleAsync"/>.
 601/// </summary>
 602public sealed class SaveArticleResult
 603{
 604    public enum ResultKind { Skipped, Saved, Failed, Navigate }
 605
 606    public ResultKind Kind { get; }
 607    public string? NavigationPath { get; }
 608
 609    private SaveArticleResult(ResultKind kind, string? path = null)
 610    {
 611        Kind = kind;
 612        NavigationPath = path;
 613    }
 614
 615    public static readonly SaveArticleResult Skipped = new(ResultKind.Skipped);
 616    public static readonly SaveArticleResult Saved = new(ResultKind.Saved);
 617    public static readonly SaveArticleResult Failed = new(ResultKind.Failed);
 618    public static SaveArticleResult NavigateTo(string path) => new(ResultKind.Navigate, path);
 619}