< Summary

Line coverage
100%
Covered lines: 18
Uncovered lines: 0
Coverable lines: 18
Total lines: 322
Line coverage: 100%
Branch coverage
100%
Covered branches: 6
Total branches: 6
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
File 1: BuildRenderTree(...)100%22100%
File 2: .ctor()100%11100%
File 2: get_EditorId()100%22100%
File 2: OnEditorUpdate(...)100%22100%
File 2: OnStatusChanged(...)100%11100%
File 2: OnGmOnlyChanged(...)100%11100%
File 2: OnSortOrderChanged(...)100%11100%

File(s)

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

#LineLine coverage
 1@using Chronicis.Shared.DTOs.Quests
 2@using Chronicis.Shared.Enums
 3@using Microsoft.JSInterop
 4
 5<div class="arc-quest-editor">
 566    @if (_quest != null)
 7    {
 8        <!-- Quest Title -->
 9        <MudTextField @bind-Value="_editTitle"
 10                      Label="Quest Title"
 11                      Variant="Variant.Outlined"
 12                      Class="mb-3"
 13                      Immediate="true"
 14                      OnBlur="SaveTitleAsync"
 15                      @onkeydown="OnTitleKeyDown" />
 16
 17        <!-- Status and Toggles -->
 18        <div class="d-flex gap-3 mb-3">
 19            <MudSelect T="QuestStatus"
 20                       Value="_editStatus"
 21                       Label="Status"
 22                       Variant="Variant.Outlined"
 23                       ValueChanged="OnStatusChanged">
 24                <MudSelectItem T="QuestStatus" Value="QuestStatus.Active">Active</MudSelectItem>
 25                <MudSelectItem T="QuestStatus" Value="QuestStatus.Completed">Completed</MudSelectItem>
 26                <MudSelectItem T="QuestStatus" Value="QuestStatus.Failed">Failed</MudSelectItem>
 27                <MudSelectItem T="QuestStatus" Value="QuestStatus.Abandoned">Abandoned</MudSelectItem>
 28            </MudSelect>
 29
 30            <MudSwitch T="bool"
 31                       Value="_editIsGmOnly"
 32                       Label="GM Only"
 33                       Color="Color.Warning"
 34                       ValueChanged="OnGmOnlyChanged" />
 35
 36            <MudNumericField T="int"
 37                             Value="_editSortOrder"
 38                             Label="Sort Order"
 39                             Variant="Variant.Outlined"
 40                             Min="0"
 41                             ValueChanged="OnSortOrderChanged" />
 42        </div>
 43
 44        <!-- Description (TipTap Editor) -->
 45        <MudText Typo="Typo.subtitle2" Class="mb-2">Description</MudText>
 46        <div id="@EditorId" class="chronicis-editor-content quest-description-editor"></div>
 47
 48        <!-- Save Status -->
 49        <div class="mt-3">
 50            <SaveStatusIndicator IsSaving="_isSaving" HasUnsavedChanges="_hasUnsavedChanges" />
 51        </div>
 52    }
 53    else
 54    {
 55        <MudAlert Severity="Severity.Info">
 56            Select a quest to edit
 57        </MudAlert>
 58    }
 59</div>

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

#LineLine coverage
 1using Chronicis.Client.Services;
 2using Chronicis.Shared.DTOs.Quests;
 3using Chronicis.Shared.Enums;
 4using Microsoft.AspNetCore.Components;
 5using Microsoft.AspNetCore.Components.Web;
 6using Microsoft.JSInterop;
 7using MudBlazor;
 8
 9namespace Chronicis.Client.Components.Quests;
 10
 11public partial class ArcQuestEditor : ComponentBase, IAsyncDisposable
 12{
 13    [Parameter]
 14    public QuestDto? Quest { get; set; }
 15
 16    [Parameter]
 17    public EventCallback<QuestDto> OnQuestUpdated { get; set; }
 18
 19    [Inject] private IQuestApiService QuestApi { get; set; } = default!;
 20    [Inject] private IJSRuntime JSRuntime { get; set; } = default!;
 21    [Inject] private ISnackbar Snackbar { get; set; } = default!;
 22
 23    private QuestDto? _quest;
 3324    private string _editTitle = string.Empty;
 3325    private string _editDescription = string.Empty;
 26    private QuestStatus _editStatus;
 27    private bool _editIsGmOnly;
 28    private int _editSortOrder;
 29
 30    private bool _isSaving;
 31    private bool _hasUnsavedChanges;
 32    private bool _editorInitialized;
 33    private bool _disposed;
 34    private DotNetObjectReference<ArcQuestEditor>? _dotNetHelper;
 35    private Timer? _autoSaveTimer;
 36
 6237    private string EditorId => $"quest-desc-editor-{_quest?.Id ?? Guid.Empty}";
 38
 39    protected override async Task OnParametersSetAsync()
 40    {
 41        // Quest changed - dispose old editor and initialize new one
 42        if (_quest?.Id != Quest?.Id)
 43        {
 44            await DisposeEditorAsync();
 45
 46            _quest = Quest;
 47
 48            if (_quest != null)
 49            {
 50                _editTitle = _quest.Title;
 51                _editDescription = _quest.Description ?? string.Empty;
 52                _editStatus = _quest.Status;
 53                _editIsGmOnly = _quest.IsGmOnly;
 54                _editSortOrder = _quest.SortOrder;
 55                _hasUnsavedChanges = false;
 56            }
 57        }
 58    }
 59
 60    protected override async Task OnAfterRenderAsync(bool firstRender)
 61    {
 62        if (firstRender)
 63        {
 64            _dotNetHelper = DotNetObjectReference.Create(this);
 65        }
 66
 67        if (_quest != null && !_editorInitialized && !_disposed && _dotNetHelper != null)
 68        {
 69            await Task.Delay(100); // Small delay to ensure DOM is ready
 70            await InitializeEditorAsync();
 71        }
 72    }
 73
 74    private async Task InitializeEditorAsync()
 75    {
 76        if (_editorInitialized || _disposed || _dotNetHelper == null)
 77            return;
 78
 79        try
 80        {
 81            await JSRuntime.InvokeVoidAsync("initializeTipTapEditor", EditorId, _editDescription, _dotNetHelper);
 82
 83            if (_disposed)
 84                return;
 85
 86            await JSRuntime.InvokeVoidAsync("initializeWikiLinkAutocomplete", EditorId, _dotNetHelper);
 87            _editorInitialized = true;
 88        }
 89        catch (ObjectDisposedException)
 90        {
 91            // Expected during navigation
 92        }
 93        catch (JSDisconnectedException)
 94        {
 95            // Expected during navigation
 96        }
 97        catch (Exception ex)
 98        {
 99            if (!_disposed)
 100            {
 101                Snackbar.Add($"Failed to initialize quest editor: {ex.Message}", Severity.Warning);
 102            }
 103        }
 104    }
 105
 106    private async Task DisposeEditorAsync()
 107    {
 108        if (_editorInitialized && !_disposed)
 109        {
 110            try
 111            {
 112                await JSRuntime.InvokeVoidAsync("destroyTipTapEditor", EditorId);
 113            }
 114            catch
 115            {
 116                // Ignore errors during disposal
 117            }
 118            finally
 119            {
 120                _editorInitialized = false;
 121            }
 122        }
 123    }
 124
 125    [JSInvokable]
 126    public void OnEditorUpdate(string html)
 127    {
 2128        _editDescription = html;
 2129        _hasUnsavedChanges = true;
 130
 131        // Debounce auto-save (0.5s delay)
 2132        _autoSaveTimer?.Dispose();
 2133        _autoSaveTimer = new Timer(async _ => await AutoSaveAsync(), null, 500, Timeout.Infinite);
 2134    }
 135
 136    private async Task AutoSaveAsync()
 137    {
 138        await InvokeAsync(async () =>
 139        {
 140            if (_hasUnsavedChanges && !_isSaving)
 141            {
 142                await SaveQuestAsync();
 143            }
 144        });
 145    }
 146
 147    private async Task OnTitleKeyDown(KeyboardEventArgs e)
 148    {
 149        if (e.Key == "Enter")
 150        {
 151            await SaveTitleAsync();
 152        }
 153    }
 154
 155    private async Task SaveTitleAsync()
 156    {
 157        if (_quest != null && _editTitle != _quest.Title)
 158        {
 159            _hasUnsavedChanges = true;
 160            await SaveQuestAsync();
 161        }
 162    }
 163
 164    private void OnStatusChanged(QuestStatus newStatus)
 165    {
 1166        _editStatus = newStatus;
 1167        _ = SaveQuestAsync();
 1168    }
 169
 170    private void OnGmOnlyChanged(bool newValue)
 171    {
 1172        _editIsGmOnly = newValue;
 1173        _ = SaveQuestAsync();
 1174    }
 175
 176    private void OnSortOrderChanged(int newValue)
 177    {
 1178        _editSortOrder = newValue;
 1179        _ = SaveQuestAsync();
 1180    }
 181
 182    private async Task SaveQuestAsync()
 183    {
 184        if (_quest == null || _isSaving)
 185            return;
 186
 187        _isSaving = true;
 188        StateHasChanged();
 189
 190        try
 191        {
 192            var editDto = new QuestEditDto
 193            {
 194                Title = string.IsNullOrWhiteSpace(_editTitle) ? null : _editTitle.Trim(),
 195                Description = string.IsNullOrWhiteSpace(_editDescription) ? null : _editDescription,
 196                Status = _editStatus,
 197                IsGmOnly = _editIsGmOnly,
 198                SortOrder = _editSortOrder,
 199                RowVersion = _quest.RowVersion
 200            };
 201
 202            var updated = await QuestApi.UpdateQuestAsync(_quest.Id, editDto);
 203
 204            if (updated != null)
 205            {
 206                // Success - update local state with server response
 207                _quest = updated;
 208                _editTitle = updated.Title;
 209                _editDescription = updated.Description ?? string.Empty;
 210                _editStatus = updated.Status;
 211                _editIsGmOnly = updated.IsGmOnly;
 212                _editSortOrder = updated.SortOrder;
 213                _hasUnsavedChanges = false;
 214
 215                await OnQuestUpdated.InvokeAsync(updated);
 216            }
 217            else
 218            {
 219                // 409 conflict was already handled by QuestApiService
 220                // Reload from server
 221                var current = await QuestApi.GetQuestAsync(_quest.Id);
 222                if (current != null)
 223                {
 224                    _quest = current;
 225                    _editTitle = current.Title;
 226                    _editDescription = current.Description ?? string.Empty;
 227                    _editStatus = current.Status;
 228                    _editIsGmOnly = current.IsGmOnly;
 229                    _editSortOrder = current.SortOrder;
 230
 231                    // Reinitialize editor with server content
 232                    await DisposeEditorAsync();
 233                    await InitializeEditorAsync();
 234                }
 235            }
 236        }
 237        catch (Exception ex)
 238        {
 239            Snackbar.Add($"Failed to save quest: {ex.Message}", Severity.Error);
 240        }
 241        finally
 242        {
 243            _isSaving = false;
 244            StateHasChanged();
 245        }
 246    }
 247
 248    public async ValueTask DisposeAsync()
 249    {
 250        if (_disposed)
 251            return;
 252
 253        _disposed = true;
 254        _autoSaveTimer?.Dispose();
 255
 256        // Properly dispose TipTap editor
 257        await DisposeEditorAsync();
 258
 259        _dotNetHelper?.Dispose();
 260
 261        GC.SuppressFinalize(this);
 262    }
 263}