< Summary

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

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
.cctor()100%11100%

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;
 31    private string _editTitle = string.Empty;
 32    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;
 39    private string _lastSaveTime = "just now";
 40
 41    public ArticleDetailViewModel(
 42        IArticleApiService articleApi,
 43        ILinkApiService linkApi,
 44        ITreeStateService treeState,
 45        IBreadcrumbService breadcrumbService,
 46        IAppContextService appContext,
 47        IArticleCacheService articleCache,
 48        IAppNavigator navigator,
 49        IUserNotifier notifier,
 50        IPageTitleService titleService,
 51        ILogger<ArticleDetailViewModel> logger)
 52    {
 53        _articleApi = articleApi;
 54        _linkApi = linkApi;
 55        _treeState = treeState;
 56        _breadcrumbService = breadcrumbService;
 57        _appContext = appContext;
 58        _articleCache = articleCache;
 59        _navigator = navigator;
 60        _notifier = notifier;
 61        _titleService = titleService;
 62        _logger = logger;
 63    }
 64
 65    // ---------------------------------------------------------------------------
 66    // Properties
 67    // ---------------------------------------------------------------------------
 68
 69    public ArticleDto? Article
 70    {
 71        get => _article;
 72        private set => SetField(ref _article, value);
 73    }
 74
 75    public List<BreadcrumbItem>? Breadcrumbs
 76    {
 77        get => _breadcrumbs;
 78        private set => SetField(ref _breadcrumbs, value);
 79    }
 80
 81    public string EditTitle
 82    {
 83        get => _editTitle;
 84        set
 85        {
 86            if (SetField(ref _editTitle, value))
 87                HasUnsavedChanges = true;
 88        }
 89    }
 90
 91    public string EditBody
 92    {
 93        get => _editBody;
 94        set
 95        {
 96            if (SetField(ref _editBody, value))
 97                HasUnsavedChanges = true;
 98        }
 99    }
 100
 101    public bool IsLoading
 102    {
 103        get => _isLoading;
 104        private set => SetField(ref _isLoading, value);
 105    }
 106
 107    public bool IsSaving
 108    {
 109        get => _isSaving;
 110        private set => SetField(ref _isSaving, value);
 111    }
 112
 113    public bool IsAutoLinking
 114    {
 115        get => _isAutoLinking;
 116        private set => SetField(ref _isAutoLinking, value);
 117    }
 118
 119    public bool IsCreatingChild
 120    {
 121        get => _isCreatingChild;
 122        private set => SetField(ref _isCreatingChild, value);
 123    }
 124
 125    public bool HasUnsavedChanges
 126    {
 127        get => _hasUnsavedChanges;
 128        set => SetField(ref _hasUnsavedChanges, value);
 129    }
 130
 131    public bool IsSummaryExpanded
 132    {
 133        get => _isSummaryExpanded;
 134        set => SetField(ref _isSummaryExpanded, value);
 135    }
 136
 137    public string LastSaveTime
 138    {
 139        get => _lastSaveTime;
 140        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    {
 204        Article = null;
 205        Breadcrumbs = null;
 206        EditTitle = string.Empty;
 207        EditBody = string.Empty;
 208        HasUnsavedChanges = false;
 209        _ = _titleService.SetTitleAsync("Chronicis");
 210    }
 211
 212    // ---------------------------------------------------------------------------
 213    // Save
 214    // ---------------------------------------------------------------------------
 215
 216    /// <summary>
 217    /// Saves the current article. The caller must pass the current editor values
 218    /// (retrieved from the razor-bound fields / JS TipTap editor) as <paramref name="currentBody"/>
 219    /// and <paramref name="currentTitle"/>. Both are required: there is no bidirectional
 220    /// binding between razor state and VM state, so the VM cannot observe edits directly.
 221    /// </summary>
 222    public async Task<SaveArticleResult> SaveArticleAsync(string currentBody, string currentTitle)
 223    {
 224        if (_article == null || IsSaving)
 225            return SaveArticleResult.Skipped;
 226
 227        // Sync editor state into VM before saving.
 228        _editBody = currentBody;
 229        EditTitle = currentTitle ?? string.Empty;
 230        IsSaving = true;
 231
 232        try
 233        {
 234            var originalTitle = _article.Title;
 235            var newTitle = EditTitle.Trim();
 236            var titleChanged = originalTitle != newTitle;
 237            string? newSlug = null;
 238
 239            if (titleChanged)
 240            {
 241                var suggestedSlug = SlugGenerator.GenerateSlug(newTitle);
 242                if (suggestedSlug != _article.Slug)
 243                    newSlug = suggestedSlug;
 244            }
 245
 246            var updateDto = new ArticleUpdateDto
 247            {
 248                Title = newTitle,
 249                Slug = newSlug,
 250                Body = _editBody,
 251                EffectiveDate = _article.EffectiveDate,
 252                IconEmoji = _article.IconEmoji
 253            };
 254
 255            var updated = await _articleApi.UpdateArticleAsync(_article.Id, updateDto);
 256            if (updated == null)
 257            {
 258                // API returned null (non-2xx or deserialization failure).
 259                // Do NOT show success, do NOT mutate local state — the server did not accept the change.
 260                _notifier.Error("Failed to save article. Please try again.");
 261                return SaveArticleResult.Failed;
 262            }
 263
 264            _articleCache.InvalidateCache();
 265
 266            // Use the server's response as the source of truth rather than optimistic local mutation.
 267            _article.Title = updated.Title;
 268            _article.Slug = updated.Slug;
 269            _article.Body = updated.Body;
 270            _article.ModifiedAt = updated.ModifiedAt;
 271            _article.IconEmoji = updated.IconEmoji;
 272            HasUnsavedChanges = false;
 273            LastSaveTime = "just now";
 274
 275            bool slugChanged = newSlug != null;
 276
 277            if (titleChanged || slugChanged)
 278            {
 279                await _titleService.SetTitleAsync(newTitle);
 280                _treeState.UpdateNodeDisplay(_article.Id, newTitle, _article.IconEmoji);
 281            }
 282
 283            if (slugChanged)
 284            {
 285                _treeState.ExpandPathToAndSelect(_article.Id);
 286                var refreshed = await _articleApi.GetArticleAsync(_article.Id);
 287                Article = refreshed;
 288
 289                if (refreshed != null)
 290                    await _navigator.GoToArticleAsync(refreshed);
 291            }
 292
 293            return SaveArticleResult.Saved;
 294        }
 295        catch (Exception ex)
 296        {
 297            _logger.LogErrorSanitized(ex, "Failed to save article {ArticleId}", _article?.Id);
 298            _notifier.Error($"Failed to save: {ex.Message}");
 299            return SaveArticleResult.Failed;
 300        }
 301        finally
 302        {
 303            IsSaving = false;
 304        }
 305    }
 306
 307    // ---------------------------------------------------------------------------
 308    // Delete
 309    // ---------------------------------------------------------------------------
 310
 311    /// <summary>
 312    /// Deletes the current article. Returns true on success.
 313    /// Caller is responsible for the confirmation dialog (JS or MudBlazor).
 314    /// </summary>
 315    public async Task<bool> DeleteArticleAsync()
 316    {
 317        if (_article == null)
 318            return false;
 319
 320        try
 321        {
 322            var success = await _articleApi.DeleteArticleAsync(_article.Id);
 323            if (success)
 324            {
 325                _articleCache.InvalidateCache();
 326                _notifier.Success("Article deleted successfully");
 327                await _treeState.RefreshAsync();
 328                Article = null;
 329                return true;
 330            }
 331            else
 332            {
 333                _notifier.Error("Failed to delete article. You may not have permission.");
 334                return false;
 335            }
 336        }
 337        catch (Exception ex)
 338        {
 339            _logger.LogErrorSanitized(ex, "Failed to delete article {ArticleId}", _article?.Id);
 340            _notifier.Error($"Failed to delete: {ex.Message}");
 341            return false;
 342        }
 343    }
 344
 345    // ---------------------------------------------------------------------------
 346    // Icon
 347    // ---------------------------------------------------------------------------
 348
 349    /// <summary>
 350    /// Saves an icon change immediately without a full save cycle.
 351    /// </summary>
 352    public async Task HandleIconChangedAsync(string? newIcon)
 353    {
 354        if (_article == null || IsSaving)
 355            return;
 356
 357        _article.IconEmoji = newIcon;
 358
 359        try
 360        {
 361            var updateDto = new ArticleUpdateDto
 362            {
 363                Title = _article.Title,
 364                Slug = _article.Slug,
 365                Body = _article.Body,
 366                EffectiveDate = _article.EffectiveDate,
 367                IconEmoji = newIcon
 368            };
 369
 370            await _articleApi.UpdateArticleAsync(_article.Id, updateDto);
 371            _treeState.UpdateNodeDisplay(_article.Id, _article.Title, newIcon);
 372        }
 373        catch (Exception ex)
 374        {
 375            _logger.LogErrorSanitized(ex, "Failed to save icon change for article {ArticleId}", _article?.Id);
 376            _notifier.Error($"Failed to save icon: {ex.Message}");
 377        }
 378    }
 379
 380    // ---------------------------------------------------------------------------
 381    // Article Creation
 382    // ---------------------------------------------------------------------------
 383
 384    /// <summary>
 385    /// Creates a root-level article in the current world.
 386    /// Returns the created article's slug for navigation, or null on failure.
 387    /// </summary>
 388    public async Task<string?> CreateRootArticleAsync()
 389    {
 390        var worldId = _appContext.CurrentWorldId;
 391        if (!worldId.HasValue || worldId == Guid.Empty)
 392        {
 393            _notifier.Warning("Please select a World first");
 394            return null;
 395        }
 396
 397        try
 398        {
 399            var createDto = new ArticleCreateDto
 400            {
 401                Title = "Untitled",
 402                Body = string.Empty,
 403                ParentId = null,
 404                EffectiveDate = DateTime.Now,
 405                WorldId = worldId.Value
 406            };
 407
 408            await _articleApi.CreateArticleAsync(createDto);
 409            await _treeState.RefreshAsync();
 410            _notifier.Success("Article created");
 411            return null; // root creation refreshes tree; no navigation needed
 412        }
 413        catch (Exception ex)
 414        {
 415            _logger.LogErrorSanitized(ex, "Failed to create root article");
 416            _notifier.Error($"Failed to create article: {ex.Message}");
 417            return null;
 418        }
 419    }
 420
 421    /// <summary>
 422    /// Creates a sibling article (same parent as current) and navigates to it.
 423    /// Returns true on success, false on failure.
 424    /// </summary>
 425    public async Task<bool> CreateSiblingArticleAsync()
 426    {
 427        if (_article == null)
 428            return false;
 429
 430        try
 431        {
 432            var createDto = new ArticleCreateDto
 433            {
 434                Title = string.Empty,
 435                Body = string.Empty,
 436                ParentId = _article.ParentId,
 437                EffectiveDate = DateTime.Now,
 438                WorldId = _article.WorldId,
 439                CampaignId = _article.CampaignId
 440            };
 441
 442            var created = await _articleApi.CreateArticleAsync(createDto);
 443            if (created == null)
 444            {
 445                _notifier.Error("Failed to create article");
 446                return false;
 447            }
 448
 449            await _treeState.RefreshAsync();
 450            _treeState.ExpandPathToAndSelect(created.Id);
 451
 452            var detail = await _articleApi.GetArticleDetailAsync(created.Id);
 453            _notifier.Success("New article created");
 454
 455            if (detail != null)
 456                await _navigator.GoToArticleAsync(detail);
 457
 458            return detail != null;
 459        }
 460        catch (Exception ex)
 461        {
 462            _logger.LogErrorSanitized(ex, "Failed to create sibling article");
 463            _notifier.Error($"Failed to create article: {ex.Message}");
 464            return false;
 465        }
 466    }
 467
 468    /// <summary>
 469    /// Creates a child article under the current article and navigates to it.
 470    /// Returns true on success, false on failure.
 471    /// </summary>
 472    public async Task<bool> CreateChildArticleAsync()
 473    {
 474        if (_article == null || IsCreatingChild)
 475            return false;
 476
 477        IsCreatingChild = true;
 478
 479        try
 480        {
 481            var createDto = new ArticleCreateDto
 482            {
 483                Title = string.Empty,
 484                Body = string.Empty,
 485                ParentId = _article.Id,
 486                EffectiveDate = DateTime.Now,
 487                WorldId = _article.WorldId,
 488                CampaignId = _article.CampaignId
 489            };
 490
 491            var created = await _articleApi.CreateArticleAsync(createDto);
 492            if (created == null)
 493            {
 494                _notifier.Error("Failed to create article");
 495                return false;
 496            }
 497
 498            await _treeState.RefreshAsync();
 499            _treeState.ExpandPathToAndSelect(created.Id);
 500
 501            var detail = await _articleApi.GetArticleDetailAsync(created.Id);
 502            _notifier.Success("New article created");
 503
 504            if (detail != null)
 505                await _navigator.GoToArticleAsync(detail);
 506
 507            return detail != null;
 508        }
 509        catch (Exception ex)
 510        {
 511            _logger.LogErrorSanitized(ex, "Failed to create child article");
 512            _notifier.Error($"Failed to create article: {ex.Message}");
 513            return false;
 514        }
 515        finally
 516        {
 517            IsCreatingChild = false;
 518        }
 519    }
 520
 521    // ---------------------------------------------------------------------------
 522    // Auto-link
 523    // ---------------------------------------------------------------------------
 524
 525    /// <summary>
 526    /// Fetches auto-link suggestions for the current article and current body content.
 527    /// Returns the result for the caller to display a confirmation dialog,
 528    /// or null if nothing was found or an error occurred.
 529    /// </summary>
 530    public async Task<AutoLinkResponseDto?> FetchAutoLinkSuggestionsAsync(string currentBody)
 531    {
 532        if (_article == null || IsAutoLinking)
 533            return null;
 534
 535        IsAutoLinking = true;
 536
 537        try
 538        {
 539            var result = await _linkApi.AutoLinkAsync(_article.Id, currentBody);
 540
 541            if (result == null)
 542            {
 543                _notifier.Error("Failed to scan for links");
 544                return null;
 545            }
 546
 547            if (result.LinksFound == 0)
 548            {
 549                _notifier.Info("No linkable content found");
 550                return null;
 551            }
 552
 553            return result;
 554        }
 555        catch (Exception ex)
 556        {
 557            _logger.LogErrorSanitized(ex, "Error auto-linking article {ArticleId}", _article?.Id);
 558            _notifier.Error($"Failed to auto-link: {ex.Message}");
 559            return null;
 560        }
 561        finally
 562        {
 563            IsAutoLinking = false;
 564        }
 565    }
 566
 567    /// <summary>
 568    /// Called after the component has applied wiki links via JS.
 569    /// Syncs the updated body and triggers a save.
 570    /// </summary>
 571    public async Task CommitAutoLinkAsync(string updatedBody, int linksApplied)
 572    {
 573        _editBody = updatedBody;
 574        HasUnsavedChanges = true;
 575        // Auto-link only modifies body; forward the VM's current title so the
 576        // save path still satisfies the required title parameter.
 577        await SaveArticleAsync(updatedBody, EditTitle);
 578        _notifier.Success($"Added {linksApplied} link(s)");
 579    }
 580
 581    // ---------------------------------------------------------------------------
 582    // Helpers
 583    // ---------------------------------------------------------------------------
 584
 585    private void RefreshBreadcrumbs()
 586    {
 587        if (_article?.Breadcrumbs != null && _article.Breadcrumbs.Any())
 588            Breadcrumbs = _breadcrumbService.ForArticle(_article);
 589        else
 590            Breadcrumbs = new List<BreadcrumbItem> { new("Dashboard", href: "/dashboard") };
 591    }
 592
 593    /// <summary>Generates the delete confirmation message including child count warning.</summary>
 594    public string GetDeleteConfirmationMessage()
 595    {
 596        if (_article == null)
 597            return string.Empty;
 598
 599        var message = $"Are you sure you want to delete '{_article.Title}'?";
 600        if (_article.ChildCount > 0)
 601        {
 602            var childText = _article.ChildCount == 1
 603                ? "1 child article"
 604                : $"{_article.ChildCount} child articles";
 605            message = $"Are you sure you want to delete '{_article.Title}'?\n\n⚠️ WARNING: This will also delete {childT
 606        }
 607
 608        return message + "\n\nThis action cannot be undone.";
 609    }
 610}
 611
 612/// <summary>
 613/// Discriminated union result for <see cref="ArticleDetailViewModel.SaveArticleAsync"/>.
 614/// </summary>
 615public sealed class SaveArticleResult
 616{
 617    public enum ResultKind { Skipped, Saved, Failed }
 618
 619    public ResultKind Kind { get; }
 620
 621    private SaveArticleResult(ResultKind kind)
 622    {
 623        Kind = kind;
 3624    }
 625
 1626    public static readonly SaveArticleResult Skipped = new(ResultKind.Skipped);
 1627    public static readonly SaveArticleResult Saved = new(ResultKind.Saved);
 1628    public static readonly SaveArticleResult Failed = new(ResultKind.Failed);
 629}