From 25040d782414abf443f519599215f9b15575597d Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Sun, 5 Apr 2026 00:21:15 +0200 Subject: [PATCH] Extract workspace campaign scope coordinator --- README.md | 2 +- RpgRoller/Components/Pages/Workspace.razor.cs | 129 ++------------ .../WorkspaceCampaignScopeCoordinator.cs | 165 ++++++++++++++++++ 3 files changed, 185 insertions(+), 111 deletions(-) create mode 100644 RpgRoller/Components/Pages/WorkspaceCampaignScopeCoordinator.cs diff --git a/README.md b/README.md index e2875a7..44255ab 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Frontend: - `RpgRoller/Components/Pages/Home.razor`: minimal gateway page (loading/auth/workspace switch) - `RpgRoller/Components/Pages/Home.razor.cs`: single `Home` code-behind with only gateway/session-view orchestration - `RpgRoller/Components/Pages/Workspace.razor`: authenticated workspace UI and workspace-specific state/logic -- `RpgRoller/Components/Pages/WorkspaceState.cs`, `WorkspaceToast.cs`, `WorkspaceFeedbackService.cs`, `WorkspaceSessionCoordinator.cs`, `WorkspaceAdminCoordinator.cs`, `WorkspaceCampaignCoordinator.cs`, `WorkspacePlayCoordinator.cs`, and `WorkspaceLiveStateController.cs`: extracted workspace UI state, toast records, feedback/status handling, session/bootstrap orchestration, admin user actions, campaign-management/modal flows, play/log coordination, and live-state reconciliation while `Workspace` remains the behavior owner +- `RpgRoller/Components/Pages/WorkspaceState.cs`, `WorkspaceToast.cs`, `WorkspaceFeedbackService.cs`, `WorkspaceSessionCoordinator.cs`, `WorkspaceAdminCoordinator.cs`, `WorkspaceCampaignCoordinator.cs`, `WorkspaceCampaignScopeCoordinator.cs`, `WorkspacePlayCoordinator.cs`, and `WorkspaceLiveStateController.cs`: extracted workspace UI state, toast records, feedback/status handling, session/bootstrap orchestration, admin user actions, campaign-management/modal flows, campaign-scope loading, play/log coordination, and live-state reconciliation while `Workspace` remains the behavior owner - `RpgRoller/Components/**/*.razor.cs`: component code-behind classes (state, handlers, parameters, injected dependencies); `.razor` files remain markup-focused - `RpgRoller/Components/Pages/Home.Models.cs`: reusable `FormState` + page form models - `RpgRoller/Components/Pages/HomeControls/`: auth, campaign management, play-screen, and modal controls extracted from `Home.razor` diff --git a/RpgRoller/Components/Pages/Workspace.razor.cs b/RpgRoller/Components/Pages/Workspace.razor.cs index 3378d87..4525577 100644 --- a/RpgRoller/Components/Pages/Workspace.razor.cs +++ b/RpgRoller/Components/Pages/Workspace.razor.cs @@ -30,92 +30,15 @@ public partial class Workspace : IAsyncDisposable return Session.LoadKnownUsernamesAsync(); } - private async Task ReloadCampaignsAsync(Guid? preferredCampaignId) - { - var campaigns = await WorkspaceQuery.GetCampaignsAsync(); - Campaigns = campaigns.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase).ToList(); + private Task ReloadCampaignsAsync(Guid? preferredCampaignId) => Scope.ReloadCampaignsAsync(preferredCampaignId); - if (Campaigns.Count == 0) - { - SelectedCampaignId = null; - await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, null); - return; - } + private Task ReloadCharacterCampaignOptionsAsync() => Scope.ReloadCharacterCampaignOptionsAsync(); - var campaignIds = Campaigns.Select(c => c.Id).ToHashSet(); - if (preferredCampaignId.HasValue && campaignIds.Contains(preferredCampaignId.Value)) - SelectedCampaignId = preferredCampaignId.Value; - else if (!SelectedCampaignId.HasValue || !campaignIds.Contains(SelectedCampaignId.Value)) - SelectedCampaignId = Campaigns[0].Id; - - await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, SelectedCampaignId?.ToString()); - } - - private async Task ReloadCharacterCampaignOptionsAsync() - { - var campaignOptions = await WorkspaceQuery.GetCharacterCampaignOptionsAsync(); - CharacterCampaignOptions = campaignOptions.OrderBy(campaign => campaign.Name, StringComparer.OrdinalIgnoreCase).ToList(); - } - - private async Task RefreshCampaignRosterAsync() - { - if (!SelectedCampaignId.HasValue) - { - SelectedCampaign = null; - SelectedCharacterId = null; - return; - } - - SelectedCampaign = await WorkspaceQuery.GetCampaignAsync(SelectedCampaignId.Value); - SyncSelectedCharacter(); - - if (IsPlayScreen && PlaySelectedCharacterId.HasValue && SelectedCharacterId != PlaySelectedCharacterId) - SelectedCharacterId = PlaySelectedCharacterId; - - await EnsureSelectedCharacterActiveAsync(); - } + private Task RefreshCampaignRosterAsync() => Scope.RefreshCampaignRosterAsync(); private Task RefreshCampaignLogAsync(Guid? afterRollId = null) => Play.RefreshCampaignLogAsync(afterRollId); - private async Task RefreshCampaignScopeAsync() - { - if (!SelectedCampaignId.HasValue) - { - SelectedCampaign = null; - SelectedCharacterSkills = []; - SelectedCharacterSkillGroups = []; - CampaignLog = []; - SelectedCharacterId = null; - ConnectionState = "offline"; - CurrentCampaignState = null; - CampaignLogCursor = null; - ResetCampaignLogDetailState(); - return; - } - - IsCampaignDataLoading = true; - try - { - await RefreshCampaignRosterAsync(); - await RefreshSelectedCharacterSheetAsync(); - await RefreshCampaignLogAsync(); - ResetCampaignStateTracking(); - } - catch (ApiRequestException ex) when (ex.StatusCode == 401) - { - ClearAuthenticatedState(); - await StopStateEventsAsync(); - await LoggedOut.InvokeAsync("Session expired. Please log in again."); - } - catch (ApiRequestException ex) - { - SetStatus(ex.Message, true); - } - finally - { - IsCampaignDataLoading = false; - } - } + private Task RefreshCampaignScopeAsync() => Scope.RefreshCampaignScopeAsync(); private Task LogoutAsync() => Session.LogoutAsync(); @@ -142,11 +65,7 @@ public partial class Workspace : IAsyncDisposable private Task DeleteUserAsync(AdminUserSummary user) => Admin.DeleteUserAsync(user); - private async Task SetMobilePanelAsync(string panel) - { - MobilePanel = string.Equals(panel, "log", StringComparison.OrdinalIgnoreCase) ? "log" : "character"; - await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", MobilePanelSessionKey, MobilePanel); - } + private Task SetMobilePanelAsync(string panel) => Scope.SetMobilePanelAsync(panel); private Task SetMobilePanelCharacterAsync() { @@ -253,27 +172,6 @@ public partial class Workspace : IAsyncDisposable return exception.Message.Contains("statically rendered", StringComparison.OrdinalIgnoreCase); } - private void SyncSelectedCharacter() - { - if (SelectedCampaign is null || SelectedCampaign.Characters.Length == 0) - { - SelectedCharacterId = null; - return; - } - - var candidateIds = SelectedCampaign.Characters.Select(c => c.Id).ToHashSet(); - if (SelectedCharacterId.HasValue && candidateIds.Contains(SelectedCharacterId.Value)) - return; - - if (ActiveCharacterId.HasValue && candidateIds.Contains(ActiveCharacterId.Value)) - { - SelectedCharacterId = ActiveCharacterId; - return; - } - - SelectedCharacterId = SelectedCampaign.Characters[0].Id; - } - private string OwnerLabel(Guid ownerUserId) { if (User is not null && ownerUserId == User.Id) @@ -415,6 +313,19 @@ public partial class Workspace : IAsyncDisposable private bool IsPlayScreen => State.IsPlayScreen; private bool IsManagementScreen => State.IsManagementScreen; private bool IsAdminScreen => State.IsAdminScreen; + private WorkspaceCampaignScopeCoordinator Scope => m_Scope ??= new( + State, + Feedback, + JS, + WorkspaceQuery, + EnsureSelectedCharacterActiveAsync, + RefreshSelectedCharacterSheetAsync, + RefreshCampaignLogAsync, + ResetCampaignLogDetailState, + ResetCampaignStateTracking, + ClearAuthenticatedState, + StopStateEventsAsync, + message => LoggedOut.InvokeAsync(message)); private WorkspaceLiveStateController Live => m_Live ??= new( State, Feedback, @@ -489,10 +400,8 @@ public partial class Workspace : IAsyncDisposable private string AdminDatabaseDownloadUrl => Navigation.ToAbsoluteUri("api/admin/database").ToString(); private const string ScreenAdmin = "admin"; - private const string CampaignSessionKey = "campaign"; - private const string MobilePanelSessionKey = "play-panel"; - private const int CampaignLogWindowSize = 25; + private WorkspaceCampaignScopeCoordinator? m_Scope; private WorkspaceLiveStateController? m_Live; private WorkspacePlayCoordinator? m_Play; private WorkspaceCampaignCoordinator? m_CampaignsFlow; diff --git a/RpgRoller/Components/Pages/WorkspaceCampaignScopeCoordinator.cs b/RpgRoller/Components/Pages/WorkspaceCampaignScopeCoordinator.cs new file mode 100644 index 0000000..fdb5fbc --- /dev/null +++ b/RpgRoller/Components/Pages/WorkspaceCampaignScopeCoordinator.cs @@ -0,0 +1,165 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.JSInterop; +using RpgRoller.Contracts; + +namespace RpgRoller.Components.Pages; + +[ExcludeFromCodeCoverage] +public sealed class WorkspaceCampaignScopeCoordinator +{ + public WorkspaceCampaignScopeCoordinator( + WorkspaceState state, + WorkspaceFeedbackService feedback, + IJSRuntime js, + WorkspaceQueryService workspaceQuery, + Func ensureSelectedCharacterActiveAsync, + Func refreshSelectedCharacterSheetAsync, + Func refreshCampaignLogAsync, + Action resetCampaignLogDetailState, + Action resetCampaignStateTracking, + Action clearAuthenticatedState, + Func stopStateEventsAsync, + Func onLoggedOutAsync) + { + m_State = state; + m_Feedback = feedback; + m_JS = js; + m_WorkspaceQuery = workspaceQuery; + m_EnsureSelectedCharacterActiveAsync = ensureSelectedCharacterActiveAsync; + m_RefreshSelectedCharacterSheetAsync = refreshSelectedCharacterSheetAsync; + m_RefreshCampaignLogAsync = refreshCampaignLogAsync; + m_ResetCampaignLogDetailState = resetCampaignLogDetailState; + m_ResetCampaignStateTracking = resetCampaignStateTracking; + m_ClearAuthenticatedState = clearAuthenticatedState; + m_StopStateEventsAsync = stopStateEventsAsync; + m_OnLoggedOutAsync = onLoggedOutAsync; + } + + public async Task ReloadCampaignsAsync(Guid? preferredCampaignId) + { + var campaigns = await m_WorkspaceQuery.GetCampaignsAsync(); + m_State.Campaigns = campaigns.OrderBy(campaign => campaign.Name, StringComparer.OrdinalIgnoreCase).ToList(); + + if (m_State.Campaigns.Count == 0) + { + m_State.SelectedCampaignId = null; + await m_JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, null); + return; + } + + var campaignIds = m_State.Campaigns.Select(campaign => campaign.Id).ToHashSet(); + if (preferredCampaignId.HasValue && campaignIds.Contains(preferredCampaignId.Value)) + m_State.SelectedCampaignId = preferredCampaignId.Value; + else if (!m_State.SelectedCampaignId.HasValue || !campaignIds.Contains(m_State.SelectedCampaignId.Value)) + m_State.SelectedCampaignId = m_State.Campaigns[0].Id; + + await m_JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, m_State.SelectedCampaignId?.ToString()); + } + + public async Task ReloadCharacterCampaignOptionsAsync() + { + var campaignOptions = await m_WorkspaceQuery.GetCharacterCampaignOptionsAsync(); + m_State.CharacterCampaignOptions = campaignOptions.OrderBy(campaign => campaign.Name, StringComparer.OrdinalIgnoreCase).ToList(); + } + + public async Task RefreshCampaignRosterAsync() + { + if (!m_State.SelectedCampaignId.HasValue) + { + m_State.SelectedCampaign = null; + m_State.SelectedCharacterId = null; + return; + } + + m_State.SelectedCampaign = await m_WorkspaceQuery.GetCampaignAsync(m_State.SelectedCampaignId.Value); + SyncSelectedCharacter(); + + if (m_State.IsPlayScreen && m_State.PlaySelectedCharacterId.HasValue && m_State.SelectedCharacterId != m_State.PlaySelectedCharacterId) + m_State.SelectedCharacterId = m_State.PlaySelectedCharacterId; + + await m_EnsureSelectedCharacterActiveAsync(); + } + + public async Task RefreshCampaignScopeAsync() + { + if (!m_State.SelectedCampaignId.HasValue) + { + m_State.SelectedCampaign = null; + m_State.SelectedCharacterSkills = []; + m_State.SelectedCharacterSkillGroups = []; + m_State.CampaignLog = []; + m_State.SelectedCharacterId = null; + m_State.ConnectionState = "offline"; + m_State.CurrentCampaignState = null; + m_State.CampaignLogCursor = null; + m_ResetCampaignLogDetailState(); + return; + } + + m_State.IsCampaignDataLoading = true; + try + { + await RefreshCampaignRosterAsync(); + await m_RefreshSelectedCharacterSheetAsync(); + await m_RefreshCampaignLogAsync(null); + m_ResetCampaignStateTracking(); + } + catch (ApiRequestException ex) when (ex.StatusCode == 401) + { + m_ClearAuthenticatedState(); + await m_StopStateEventsAsync(); + await m_OnLoggedOutAsync("Session expired. Please log in again."); + } + catch (ApiRequestException ex) + { + m_Feedback.SetStatus(ex.Message, true); + } + finally + { + m_State.IsCampaignDataLoading = false; + } + } + + public async Task SetMobilePanelAsync(string panel) + { + m_State.MobilePanel = string.Equals(panel, "log", StringComparison.OrdinalIgnoreCase) ? "log" : "character"; + await m_JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", MobilePanelSessionKey, m_State.MobilePanel); + } + + private void SyncSelectedCharacter() + { + if (m_State.SelectedCampaign is null || m_State.SelectedCampaign.Characters.Length == 0) + { + m_State.SelectedCharacterId = null; + return; + } + + var candidateIds = m_State.SelectedCampaign.Characters.Select(character => character.Id).ToHashSet(); + if (m_State.SelectedCharacterId.HasValue && candidateIds.Contains(m_State.SelectedCharacterId.Value)) + return; + + if (m_State.ActiveCharacterId.HasValue && candidateIds.Contains(m_State.ActiveCharacterId.Value)) + { + m_State.SelectedCharacterId = m_State.ActiveCharacterId; + return; + } + + m_State.SelectedCharacterId = m_State.SelectedCampaign.Characters[0].Id; + } + + private readonly Action m_ClearAuthenticatedState; + private readonly Func m_EnsureSelectedCharacterActiveAsync; + private readonly WorkspaceFeedbackService m_Feedback; + private readonly IJSRuntime m_JS; + private readonly Func m_OnLoggedOutAsync; + private readonly Func m_RefreshCampaignLogAsync; + private readonly Func m_RefreshSelectedCharacterSheetAsync; + private readonly Action m_ResetCampaignLogDetailState; + private readonly Action m_ResetCampaignStateTracking; + private readonly WorkspaceState m_State; + private readonly Func m_StopStateEventsAsync; + private readonly WorkspaceQueryService m_WorkspaceQuery; + + private const string CampaignSessionKey = "campaign"; + private const string MobilePanelSessionKey = "play-panel"; +}