< Summary

Information
Class: Chronicis.Client.Components.Quests.QuestDrawer
Assembly: Chronicis.Client
File(s): /home/runner/work/chronicis/chronicis/src/Chronicis.Client/Components/Quests/QuestDrawer.razor.cs
Line coverage
100%
Covered lines: 7
Uncovered lines: 0
Coverable lines: 7
Total lines: 557
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%
OnAutocompleteHidden()100%11100%
OnAutocompleteArrowDown()100%11100%
OnAutocompleteArrowUp()100%11100%

File(s)

/home/runner/work/chronicis/chronicis/src/Chronicis.Client/Components/Quests/QuestDrawer.razor.cs

#LineLine coverage
 1using Chronicis.Client.Models;
 2using Chronicis.Client.Services;
 3using Chronicis.Shared.DTOs;
 4using Chronicis.Shared.DTOs.Quests;
 5using Chronicis.Shared.Enums;
 6using Microsoft.AspNetCore.Components;
 7using Microsoft.AspNetCore.Components.Web;
 8using Microsoft.JSInterop;
 9using MudBlazor;
 10
 11namespace Chronicis.Client.Components.Quests;
 12
 13public partial class QuestDrawer : IAsyncDisposable
 14{
 15    [Inject] private IArticleApiService ArticleApi { get; set; } = null!;
 16
 17    [Parameter]
 18    public bool IsOpen { get; set; }
 19
 20    private bool _isLoading;
 21    private bool _isSubmitting;
 22    private bool _loadingUpdates;
 23    private string? _emptyStateMessage;
 24    private string? _loadingError;
 25    private string? _validationError;
 26
 27    private List<QuestDto>? _quests;
 28    private Guid? _selectedQuestId;
 29    private QuestDto? _selectedQuest;
 30    private List<QuestUpdateEntryDto>? _recentUpdates;
 31
 32    private Guid? _currentArcId;
 33    private Guid? _currentSessionId;
 34    private Guid? _currentWorldId;
 35    private bool _canAssociateSession;
 6236    private bool _associateWithSession = true;
 37
 38    private IJSObjectReference? _editorModule;
 39    private bool _editorInitialized;
 40    private DotNetObjectReference<QuestDrawer>? _dotNetRef;
 41    private bool _disposed;
 42    private bool _questsLoadedForArc;
 43    private bool _needsEditorInit; // Flag to trigger editor init after render
 44    private bool _lastIsOpen;
 45
 46    protected override async Task OnParametersSetAsync()
 47    {
 48        if (IsOpen == _lastIsOpen)
 49        {
 50            return;
 51        }
 52
 53        _lastIsOpen = IsOpen;
 54
 55        if (IsOpen)
 56        {
 57            await HandleOpenAsync();
 58        }
 59        else
 60        {
 61            await HandleCloseAsync();
 62        }
 63    }
 64
 65    protected override async Task OnAfterRenderAsync(bool firstRender)
 66    {
 67        // Initialize editor after DOM has been updated
 68        if (_needsEditorInit && !_editorInitialized)
 69        {
 70            _needsEditorInit = false;
 71            await InitializeEditorAsync();
 72        }
 73    }
 74
 75    private async Task HandleOpenAsync()
 76    {
 77        await LoadQuestsAsync();
 78        StateHasChanged();
 79
 80        // Focus the first quest selector after render
 81        if (_quests?.Any() == true)
 82        {
 83            await Task.Delay(100);
 84            await FocusFirstQuestAsync();
 85        }
 86    }
 87
 88    private async Task HandleCloseAsync()
 89    {
 90        await DisposeEditorAsync();
 91        _selectedQuestId = null;
 92        _selectedQuest = null;
 93        _recentUpdates = null;
 94        _validationError = null;
 95        _questsLoadedForArc = false;
 96        _quests = null;
 97        StateHasChanged();
 98    }
 99
 100    private async Task CloseDrawer()
 101    {
 102        QuestDrawerService.Close();
 103
 104        // Give the drawer host time to animate closed, then return focus to main content.
 105        await Task.Delay(200);
 106        await RestoreFocusAsync();
 107    }
 108
 109    private async Task LoadQuestsAsync()
 110    {
 111        _isLoading = true;
 112        _emptyStateMessage = null;
 113        _loadingError = null;
 114
 115        try
 116        {
 117            var selectedNodeId = TreeState.SelectedNodeId;
 118            if (!selectedNodeId.HasValue)
 119            {
 120                _emptyStateMessage = "No article selected. Navigate to a session to use quest tracking.";
 121                return;
 122            }
 123
 124            Guid? incomingArcId;
 125
 126            if (TreeState.TryGetNode(selectedNodeId.Value, out var selectedNode)
 127                && selectedNode != null
 128                && selectedNode.NodeType == TreeNodeType.Session)
 129            {
 130                _currentWorldId = selectedNode.WorldId;
 131                incomingArcId = selectedNode.ArcId;
 132                _currentSessionId = selectedNode.Id;
 133                _canAssociateSession = true;
 134            }
 135            else
 136            {
 137                // Resolve Arc and Session from current article
 138                var selectedArticle = await GetCurrentArticleAsync();
 139
 140                if (selectedArticle == null)
 141                {
 142                    _emptyStateMessage = "No article selected. Navigate to a session to use quest tracking.";
 143                    return;
 144                }
 145
 146                // Store the world ID for autocomplete
 147                _currentWorldId = selectedArticle.WorldId;
 148
 149                if (selectedArticle.Type != ArticleType.Session && selectedArticle.Type != ArticleType.SessionNote)
 150                {
 151                    _emptyStateMessage = "Navigate to a session or session note to use quest tracking.";
 152                    return;
 153                }
 154
 155                incomingArcId = selectedArticle.ArcId;
 156                if (selectedArticle.Type == ArticleType.Session)
 157                {
 158                    // Legacy session article IDs were migrated to Session entity IDs 1:1 in Phase 1.
 159                    _currentSessionId = selectedArticle.Id;
 160                    _canAssociateSession = true;
 161                }
 162                else
 163                {
 164                    _currentSessionId = await ResolveSessionIdFromParentAsync(selectedArticle.Id);
 165                    _canAssociateSession = _currentSessionId.HasValue;
 166                }
 167            }
 168
 169            if (!incomingArcId.HasValue)
 170            {
 171                _emptyStateMessage = "This session is not associated with an arc.";
 172                return;
 173            }
 174
 175            // Reset cache if the arc has changed
 176            if (_currentArcId != incomingArcId)
 177            {
 178                _questsLoadedForArc = false;
 179                _quests = null;
 180                _selectedQuestId = null;
 181                _selectedQuest = null;
 182                _recentUpdates = null;
 183            }
 184
 185            _currentArcId = incomingArcId;
 186
 187            // Only load quests if we haven't already loaded for this arc (prevent duplicate fetches)
 188            if (!_questsLoadedForArc)
 189            {
 190                var allQuests = await QuestApi.GetArcQuestsAsync(_currentArcId.Value);
 191                // The drawer is a player-facing view â€” GM-only quests are never shown here
 192                _quests = allQuests.Where(q => !q.IsGmOnly).ToList();
 193                _questsLoadedForArc = true;
 194            }
 195
 196            // Auto-select first quest if available and none selected
 197            if (_quests?.Any() == true && !_selectedQuestId.HasValue)
 198            {
 199                await SelectQuest(_quests.First().Id);
 200            }
 201        }
 202        catch (Exception ex)
 203        {
 204            Logger.LogError(ex, "Failed to load quests");
 205            _loadingError = ex.Message;
 206            _quests = null;
 207        }
 208        finally
 209        {
 210            _isLoading = false;
 211        }
 212    }
 213
 214    private async Task<ArticleDto?> GetCurrentArticleAsync()
 215    {
 216        var selectedNodeId = TreeState.SelectedNodeId;
 217
 218        if (!selectedNodeId.HasValue)
 219            return null;
 220
 221        try
 222        {
 223            return await ArticleApi.GetArticleDetailAsync(selectedNodeId.Value);
 224        }
 225        catch
 226        {
 227            return null;
 228        }
 229    }
 230
 231    private async Task<Guid?> ResolveSessionIdFromParentAsync(Guid articleId)
 232    {
 233        try
 234        {
 235            var article = await ArticleApi.GetArticleDetailAsync(articleId);
 236            return article?.SessionId;
 237        }
 238        catch (Exception ex)
 239        {
 240            Logger.LogError(ex, "Failed to resolve session ID");
 241        }
 242
 243        return null;
 244    }
 245
 246    private async Task SelectQuest(Guid questId)
 247    {
 248        if (_selectedQuestId == questId)
 249            return;
 250
 251        // Dispose old editor if switching quests
 252        if (_editorInitialized)
 253        {
 254            await DisposeEditorAsync();
 255        }
 256
 257        _selectedQuestId = questId;
 258        _selectedQuest = _quests?.FirstOrDefault(q => q.Id == questId);
 259        _validationError = null;
 260
 261        if (_selectedQuest != null)
 262        {
 263            // Load recent updates
 264            await LoadRecentUpdatesAsync(questId);
 265
 266            // Set flag to initialize editor after next render
 267            _needsEditorInit = true;
 268        }
 269
 270        // Trigger render - OnAfterRenderAsync will handle editor init
 271        StateHasChanged();
 272    }
 273
 274    private async Task HandleQuestItemKeyDown(KeyboardEventArgs e, Guid questId)
 275    {
 276        if (e.Key == "Enter" || e.Key == " ")
 277        {
 278            await SelectQuest(questId);
 279        }
 280    }
 281
 282    private async Task LoadRecentUpdatesAsync(Guid questId)
 283    {
 284        _loadingUpdates = true;
 285        StateHasChanged();
 286
 287        try
 288        {
 289            var result = await QuestApi.GetQuestUpdatesAsync(questId, skip: 0, take: 5);
 290            _recentUpdates = result.Items;
 291        }
 292        catch (Exception ex)
 293        {
 294            Logger.LogError(ex, "Failed to load quest updates");
 295            _recentUpdates = new List<QuestUpdateEntryDto>();
 296        }
 297        finally
 298        {
 299            _loadingUpdates = false;
 300            StateHasChanged();
 301        }
 302    }
 303
 304    private async Task InitializeEditorAsync()
 305    {
 306        if (_editorInitialized || _disposed)
 307            return;
 308
 309        try
 310        {
 311            _dotNetRef = DotNetObjectReference.Create(this);
 312            _editorModule = await JSRuntime.InvokeAsync<IJSObjectReference>(
 313                "import", "./js/questEditor.js?v=3");
 314
 315            await _editorModule.InvokeVoidAsync("initializeEditor", "quest-update-editor", _dotNetRef);
 316            _editorInitialized = true;
 317
 318            // Focus the editor after initialization
 319            await Task.Delay(50);
 320            await FocusEditorAsync();
 321        }
 322        catch (Exception ex)
 323        {
 324            Logger.LogError(ex, "Failed to initialize quest editor");
 325            Snackbar.Add("Failed to initialize editor", Severity.Warning);
 326        }
 327    }
 328
 329    private async Task DisposeEditorAsync()
 330    {
 331        if (!_editorInitialized || _disposed)
 332            return;
 333
 334        try
 335        {
 336            if (_editorModule != null)
 337            {
 338                await _editorModule.InvokeVoidAsync("destroyEditor");
 339            }
 340        }
 341        catch (Exception ex)
 342        {
 343            Logger.LogError(ex, "Error disposing quest editor");
 344        }
 345        finally
 346        {
 347            _editorInitialized = false;
 348        }
 349    }
 350
 351    private async Task SubmitUpdate()
 352    {
 353        if (_selectedQuest == null || _isSubmitting)
 354            return;
 355
 356        _isSubmitting = true;
 357        _validationError = null;
 358
 359        try
 360        {
 361            // Get editor content
 362            if (_editorModule == null)
 363            {
 364                _validationError = "Editor not initialized";
 365                return;
 366            }
 367
 368            var content = await _editorModule.InvokeAsync<string>("getEditorContent");
 369
 370            // Validate content is not empty or whitespace
 371            if (string.IsNullOrWhiteSpace(content) || content == "<p></p>" || content == "<p><br></p>")
 372            {
 373                _validationError = "Update content cannot be empty";
 374                return;
 375            }
 376
 377            // Create update DTO
 378            var createDto = new QuestUpdateCreateDto
 379            {
 380                Body = content,
 381                SessionId = _associateWithSession && _canAssociateSession ? _currentSessionId : null
 382            };
 383
 384            // Submit via API
 385            var result = await QuestApi.AddQuestUpdateAsync(_selectedQuest.Id, createDto);
 386
 387            if (result != null)
 388            {
 389                // Clear editor
 390                await _editorModule.InvokeVoidAsync("clearEditor");
 391
 392                // Refresh updates list
 393                await LoadRecentUpdatesAsync(_selectedQuest.Id);
 394
 395                // Update the quest's UpdatedAt (refresh from server if needed)
 396                var refreshedQuest = await QuestApi.GetQuestAsync(_selectedQuest.Id);
 397                if (refreshedQuest != null)
 398                {
 399                    var questInList = _quests?.FirstOrDefault(q => q.Id == _selectedQuest.Id);
 400                    if (questInList != null)
 401                    {
 402                        questInList.UpdatedAt = refreshedQuest.UpdatedAt;
 403                        questInList.UpdateCount = refreshedQuest.UpdateCount;
 404                    }
 405
 406                    _selectedQuest = refreshedQuest;
 407                }
 408
 409                Snackbar.Add("Quest update added", Severity.Success);
 410            }
 411            else
 412            {
 413                _validationError = "Failed to add quest update";
 414            }
 415        }
 416        catch (Exception ex)
 417        {
 418            Logger.LogError(ex, "Failed to submit quest update");
 419            _validationError = $"Error: {ex.Message}";
 420        }
 421        finally
 422        {
 423            _isSubmitting = false;
 424            StateHasChanged();
 425        }
 426    }
 427
 428    private async Task FocusFirstQuestAsync()
 429    {
 430        try
 431        {
 432            await JSRuntime.InvokeVoidAsync("eval",
 433                "document.querySelector('.quest-item')?.focus()");
 434        }
 435        catch
 436        {
 437            // Ignore focus errors
 438        }
 439    }
 440
 441    private async Task FocusEditorAsync()
 442    {
 443        try
 444        {
 445            if (_editorModule != null)
 446            {
 447                await _editorModule.InvokeVoidAsync("focusEditor");
 448            }
 449        }
 450        catch
 451        {
 452            // Ignore focus errors
 453        }
 454    }
 455
 456    private async Task RestoreFocusAsync()
 457    {
 458        try
 459        {
 460            await JSRuntime.InvokeVoidAsync("eval",
 461                "document.querySelector('.chronicis-article-card')?.focus() || document.querySelector('main')?.focus()")
 462        }
 463        catch
 464        {
 465            // Ignore focus errors
 466        }
 467    }
 468
 469    #region Wiki Link Autocomplete
 470
 471    [JSInvokable]
 472    public async Task OnAutocompleteTriggered(string query, double x, double y)
 473    {
 474        await AutocompleteService.ShowAsync(query, x, y, _currentWorldId);
 475    }
 476
 477    [JSInvokable]
 478    public Task OnAutocompleteHidden()
 479    {
 1480        AutocompleteService.Hide();
 1481        return Task.CompletedTask;
 482    }
 483
 484    [JSInvokable]
 485    public Task OnAutocompleteArrowDown()
 486    {
 1487        AutocompleteService.SelectNext();
 1488        return Task.CompletedTask;
 489    }
 490
 491    [JSInvokable]
 492    public Task OnAutocompleteArrowUp()
 493    {
 1494        AutocompleteService.SelectPrevious();
 1495        return Task.CompletedTask;
 496    }
 497
 498    [JSInvokable]
 499    public async Task OnAutocompleteEnter()
 500    {
 501        var selected = AutocompleteService.GetSelectedSuggestion();
 502        if (selected != null)
 503        {
 504            await HandleAutocompleteSuggestionSelected(selected);
 505        }
 506    }
 507
 508    private async Task HandleAutocompleteSuggestionSelected(WikiLinkAutocompleteItem suggestion)
 509    {
 510        if (_editorModule == null)
 511            return;
 512
 513        try
 514        {
 515            if (suggestion.IsExternal)
 516            {
 517                // Insert external link: [[srd/spell-name]]
 518                var linkText = $"{AutocompleteService.ExternalSourceKey}/{suggestion.ExternalKey}";
 519                await _editorModule.InvokeVoidAsync("insertWikiLink", linkText, suggestion.DisplayText);
 520            }
 521            else
 522            {
 523                // Insert internal article link: [[article-title]]
 524                await _editorModule.InvokeVoidAsync("insertWikiLink", suggestion.DisplayText, null);
 525            }
 526
 527            // Hide autocomplete
 528            AutocompleteService.Hide();
 529        }
 530        catch (Exception ex)
 531        {
 532            Logger.LogError(ex, "Failed to insert wiki link suggestion");
 533        }
 534    }
 535
 536    #endregion
 537
 538    public async ValueTask DisposeAsync()
 539    {
 540        if (_disposed)
 541            return;
 542
 543        _disposed = true;
 544
 545        // Properly dispose TipTap editor
 546        await DisposeEditorAsync();
 547
 548        _dotNetRef?.Dispose();
 549
 550        if (_editorModule != null)
 551        {
 552            await _editorModule.DisposeAsync();
 553        }
 554
 555        GC.SuppressFinalize(this);
 556    }
 557}