diff --git a/FAQ.md b/FAQ.md index dd6fc7e..359a1ec 100644 --- a/FAQ.md +++ b/FAQ.md @@ -74,4 +74,4 @@ There is no separate activate button in Play. The selected character in the char ## Why is auth form state kept in `AuthSection` instead of `Home`? -Auth inputs and inline validation are transient UI concerns, so they now live in `AuthSection`. `Home` keeps only session/workspace state and API workflows, and returns `FormSubmissionResult` back to controls for display. +Auth inputs, validation, and submit workflows are transient UI concerns, so they now live in `AuthSection`. `Home` keeps shared session/workspace state and cross-control refresh/orchestration only. diff --git a/FRONTEND_PROGRESS.md b/FRONTEND_PROGRESS.md index 1e31c17..a0fdf67 100644 --- a/FRONTEND_PROGRESS.md +++ b/FRONTEND_PROGRESS.md @@ -8,6 +8,7 @@ Tracking against `UX.md` tasks and decisions. - Runtime frontend stack: Blazor (`RpgRoller/Components/*`) + browser JS interop (`wwwroot/js/rpgroller-api.js`) - Legacy TypeScript frontend/runtime artifacts: removed - Home page orchestration split by concern (`Home.*.cs` partials + `HomeControls/*`) to reduce merge churn and keep auth/campaign/character/skill flows isolated. +- Concern controls now own their local form state and mutation workflows; `Home` handles shared cross-control state refresh. ## UX Checklist diff --git a/README.md b/README.md index f338310..5f605b5 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,8 @@ Frontend: - `RpgRoller/Components/Pages/Home.*.cs`: concern-based partial class split (`State`, `Lifecycle`, `Auth`, `Campaign`, `Character`, `Skill`, `Realtime`, `Api`, `Presentation`, `Validation`) - `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` -- Form ownership model: controls own transient form/error state; `Home` receives typed submissions and returns `FormSubmissionResult` for server/business validation feedback +- Form ownership model: controls own transient form/error state and execute their concern-specific API mutations directly +- `RpgRoller/Components/RpgRollerApiClient.cs`: shared browser API client used by `Home` and leaf controls - `RpgRoller/wwwroot/js/rpgroller-api.js`: browser-side API + SSE + session storage interop for Blazor - `RpgRoller/wwwroot/styles.css`: responsive UX styling and theme tokens diff --git a/RpgRoller/Components/Pages/Home.Api.cs b/RpgRoller/Components/Pages/Home.Api.cs index bd57e7e..93a7bea 100644 --- a/RpgRoller/Components/Pages/Home.Api.cs +++ b/RpgRoller/Components/Pages/Home.Api.cs @@ -1,51 +1,20 @@ -using System.Text.Json; -using Microsoft.JSInterop; +using Microsoft.AspNetCore.Components; +using RpgRoller.Components; namespace RpgRoller.Components.Pages; public partial class Home { - private async Task RequestAsync(string method, string path, object? payload = null) + [Inject] + private RpgRollerApiClient ApiClient { get; set; } = default!; + + private Task RequestAsync(string method, string path, object? payload = null) { - var response = await JS.InvokeAsync("rpgRollerApi.request", method, path, payload); - if (!response.Ok) - { - throw new ApiRequestException(response.Status, response.Error ?? "Request failed."); - } - - if (response.Data.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null) - { - return default!; - } - - return response.Data.Deserialize(JsonOptions)!; + return ApiClient.RequestAsync(method, path, payload); } - private async Task RequestWithoutPayloadAsync(string method, string path) + private Task RequestWithoutPayloadAsync(string method, string path) { - var response = await JS.InvokeAsync("rpgRollerApi.request", method, path, null); - if (!response.Ok) - { - throw new ApiRequestException(response.Status, response.Error ?? "Request failed."); - } - } - - private sealed class JsApiResponse - { - public bool Ok { get; set; } - public int Status { get; set; } - public string? Error { get; set; } - public JsonElement Data { get; set; } - } - - private sealed class ApiRequestException : Exception - { - public ApiRequestException(int statusCode, string message) - : base(message) - { - StatusCode = statusCode; - } - - public int StatusCode { get; } + return ApiClient.RequestWithoutPayloadAsync(method, path); } } diff --git a/RpgRoller/Components/Pages/Home.Auth.cs b/RpgRoller/Components/Pages/Home.Auth.cs index 061f478..f9b4832 100644 --- a/RpgRoller/Components/Pages/Home.Auth.cs +++ b/RpgRoller/Components/Pages/Home.Auth.cs @@ -1,97 +1,19 @@ -using RpgRoller.Contracts; +using RpgRoller.Components; namespace RpgRoller.Components.Pages; public partial class Home { - private async Task RegisterAsync(RegisterFormModel model) + private async Task OnLoggedInAsync() { - var validationErrors = new Dictionary(); - - AddRequiredError(validationErrors, "username", model.Username, "Username is required."); - AddRequiredError(validationErrors, "displayName", model.DisplayName, "Display name is required."); - - if (string.IsNullOrWhiteSpace(model.Password) || model.Password.Length < 8) - { - validationErrors["password"] = "Password must be at least 8 characters."; - } - - if (validationErrors.Count > 0) - { - return new FormSubmissionResult - { - ErrorMessage = "Resolve validation issues before submitting.", - Errors = validationErrors - }; - } - - IsMutating = true; try { - _ = await RequestAsync( - "POST", - "/api/auth/register", - new RegisterRequest(model.Username.Trim(), model.Password, model.DisplayName.Trim())); - - SetStatus("Registration successful. You can log in now.", false); - return new FormSubmissionResult(); - } - catch (ApiRequestException ex) - { - if (ex.Message.Contains("already taken", StringComparison.OrdinalIgnoreCase)) - { - return new FormSubmissionResult - { - Errors = new Dictionary - { - ["username"] = "Username is already taken. Choose another one." - } - }; - } - - return new FormSubmissionResult { ErrorMessage = ex.Message }; - } - finally - { - IsMutating = false; - } - } - - private async Task LoginAsync(LoginFormModel model) - { - var validationErrors = new Dictionary(); - - AddRequiredError(validationErrors, "username", model.Username, "Username is required."); - AddRequiredError(validationErrors, "password", model.Password, "Password is required."); - - if (validationErrors.Count > 0) - { - return new FormSubmissionResult - { - ErrorMessage = "Resolve validation issues before submitting.", - Errors = validationErrors - }; - } - - IsMutating = true; - try - { - _ = await RequestAsync( - "POST", - "/api/auth/login", - new LoginRequest(model.Username.Trim(), model.Password)); - await ReloadAuthenticatedSessionAsync(null); SetStatus("Logged in.", false); - return new FormSubmissionResult(); } catch (ApiRequestException ex) { - return new FormSubmissionResult { ErrorMessage = ex.Message }; - } - finally - { - IsMutating = false; + SetStatus(ex.Message, true); } } diff --git a/RpgRoller/Components/Pages/Home.Campaign.cs b/RpgRoller/Components/Pages/Home.Campaign.cs index cf8f1f1..11e9ba5 100644 --- a/RpgRoller/Components/Pages/Home.Campaign.cs +++ b/RpgRoller/Components/Pages/Home.Campaign.cs @@ -1,6 +1,5 @@ using Microsoft.AspNetCore.Components; using Microsoft.JSInterop; -using RpgRoller.Contracts; namespace RpgRoller.Components.Pages; @@ -51,43 +50,11 @@ public partial class Home await SyncStateEventsAsync(); } - private async Task CreateCampaignAsync(CampaignFormModel model) + private async Task OnCampaignCreatedAsync(Guid campaignId) { - var validationErrors = new Dictionary(); - - AddRequiredError(validationErrors, "name", model.Name, "Campaign name is required."); - AddRequiredError(validationErrors, "rulesetId", model.RulesetId, "Ruleset is required."); - - if (validationErrors.Count > 0) - { - return new FormSubmissionResult - { - ErrorMessage = "Resolve validation issues before submitting.", - Errors = validationErrors - }; - } - - IsMutating = true; - try - { - var campaign = await RequestAsync( - "POST", - "/api/campaigns", - new CreateCampaignRequest(model.Name.Trim(), model.RulesetId)); - - await ReloadCampaignsAsync(campaign.Id); - await RefreshCampaignScopeAsync(); - await SyncStateEventsAsync(); - SetStatus("Campaign created.", false); - return new FormSubmissionResult(); - } - catch (ApiRequestException ex) - { - return new FormSubmissionResult { ErrorMessage = ex.Message }; - } - finally - { - IsMutating = false; - } + await ReloadCampaignsAsync(campaignId); + await RefreshCampaignScopeAsync(); + await SyncStateEventsAsync(); + SetStatus("Campaign created.", false); } } diff --git a/RpgRoller/Components/Pages/Home.Character.cs b/RpgRoller/Components/Pages/Home.Character.cs index 895c47e..312edb7 100644 --- a/RpgRoller/Components/Pages/Home.Character.cs +++ b/RpgRoller/Components/Pages/Home.Character.cs @@ -35,100 +35,22 @@ public partial class Home EditingCharacterId = null; } - private async Task CreateCharacterAsync(CharacterFormModel model) + private async Task OnCharacterCreatedAsync(Guid campaignId) { - var validationErrors = new Dictionary(); - - AddRequiredError(validationErrors, "name", model.Name, "Character name is required."); - var hasCampaignId = TryParseGuid( - model.CampaignId, - validationErrors, - "campaignId", - "Campaign is required.", - out var campaignId); - - if (validationErrors.Count > 0 || !hasCampaignId) - { - return new FormSubmissionResult - { - ErrorMessage = "Resolve validation issues before submitting.", - Errors = validationErrors - }; - } - - IsMutating = true; - try - { - _ = await RequestAsync( - "POST", - "/api/characters", - new CreateCharacterRequest(model.Name.Trim(), campaignId)); - - CloseCharacterModals(); - await ReloadCampaignsAsync(campaignId); - await RefreshCampaignScopeAsync(); - await SyncStateEventsAsync(); - SetStatus("Character created.", false); - return new FormSubmissionResult(); - } - catch (ApiRequestException ex) - { - return new FormSubmissionResult { ErrorMessage = ex.Message }; - } - finally - { - IsMutating = false; - } + CloseCharacterModals(); + await ReloadCampaignsAsync(campaignId); + await RefreshCampaignScopeAsync(); + await SyncStateEventsAsync(); + SetStatus("Character created.", false); } - private async Task UpdateCharacterAsync(CharacterFormModel model) + private async Task OnCharacterUpdatedAsync(Guid campaignId) { - if (!EditingCharacterId.HasValue) - { - return new FormSubmissionResult { ErrorMessage = "No character selected." }; - } - - var validationErrors = new Dictionary(); - AddRequiredError(validationErrors, "name", model.Name, "Character name is required."); - var hasCampaignId = TryParseGuid( - model.CampaignId, - validationErrors, - "campaignId", - "Campaign is required.", - out var campaignId); - - if (validationErrors.Count > 0 || !hasCampaignId) - { - return new FormSubmissionResult - { - ErrorMessage = "Resolve validation issues before submitting.", - Errors = validationErrors - }; - } - - IsMutating = true; - try - { - var updatedCharacter = await RequestAsync( - "PUT", - $"/api/characters/{EditingCharacterId.Value}", - new UpdateCharacterRequest(model.Name.Trim(), campaignId)); - - CloseCharacterModals(); - await ReloadCampaignsAsync(updatedCharacter.CampaignId); - await RefreshCampaignScopeAsync(); - await SyncStateEventsAsync(); - SetStatus("Character updated.", false); - return new FormSubmissionResult(); - } - catch (ApiRequestException ex) - { - return new FormSubmissionResult { ErrorMessage = ex.Message }; - } - finally - { - IsMutating = false; - } + CloseCharacterModals(); + await ReloadCampaignsAsync(campaignId); + await RefreshCampaignScopeAsync(); + await SyncStateEventsAsync(); + SetStatus("Character updated.", false); } private async Task SelectCharacterAsync(Guid characterId) diff --git a/RpgRoller/Components/Pages/Home.Models.cs b/RpgRoller/Components/Pages/Home.Models.cs index d186621..ab327e8 100644 --- a/RpgRoller/Components/Pages/Home.Models.cs +++ b/RpgRoller/Components/Pages/Home.Models.cs @@ -14,13 +14,6 @@ public sealed class FormState } } -public sealed class FormSubmissionResult -{ - public Dictionary Errors { get; init; } = []; - public string? ErrorMessage { get; init; } - public bool IsSuccess => Errors.Count == 0 && string.IsNullOrWhiteSpace(ErrorMessage); -} - public sealed class RegisterFormModel { public string Username { get; set; } = string.Empty; diff --git a/RpgRoller/Components/Pages/Home.Skill.cs b/RpgRoller/Components/Pages/Home.Skill.cs index f4bd385..f084160 100644 --- a/RpgRoller/Components/Pages/Home.Skill.cs +++ b/RpgRoller/Components/Pages/Home.Skill.cs @@ -44,101 +44,19 @@ public partial class Home EditingSkillId = null; } - private async Task CreateSkillAsync(SkillFormModel model) + private async Task OnSkillCreatedAsync(Guid _) { - if (SelectedCharacter is null) - { - return new FormSubmissionResult { ErrorMessage = "Select a character first." }; - } - - var validationErrors = new Dictionary(); - AddRequiredError(validationErrors, "name", model.Name, "Skill name is required."); - AddRequiredError(validationErrors, "diceRollDefinition", model.DiceRollDefinition, "Expression is required."); - - if (IsSelectedCampaignD6 && model.WildDice < 1) - { - validationErrors["wildDice"] = "D6 skills require at least one wild die."; - } - - if (validationErrors.Count > 0) - { - return new FormSubmissionResult - { - ErrorMessage = "Resolve validation issues before submitting.", - Errors = validationErrors - }; - } - - IsMutating = true; - try - { - _ = await RequestAsync( - "POST", - $"/api/characters/{SelectedCharacter.Id}/skills", - new CreateSkillRequest(model.Name.Trim(), model.DiceRollDefinition.Trim(), model.WildDice, model.AllowFumble)); - - CloseSkillModals(); - await RefreshCampaignScopeAsync(); - SetStatus("Skill created.", false); - return new FormSubmissionResult(); - } - catch (ApiRequestException ex) - { - return new FormSubmissionResult { ErrorMessage = ex.Message }; - } - finally - { - IsMutating = false; - } + CloseSkillModals(); + await RefreshCampaignScopeAsync(); + SetStatus("Skill created.", false); } - private async Task UpdateSkillAsync(SkillFormModel model) + private async Task OnSkillUpdatedAsync(Guid skillId) { - if (!EditingSkillId.HasValue) - { - return new FormSubmissionResult { ErrorMessage = "No skill selected." }; - } - - var validationErrors = new Dictionary(); - AddRequiredError(validationErrors, "name", model.Name, "Skill name is required."); - AddRequiredError(validationErrors, "diceRollDefinition", model.DiceRollDefinition, "Expression is required."); - - if (IsSelectedCampaignD6 && model.WildDice < 1) - { - validationErrors["wildDice"] = "D6 skills require at least one wild die."; - } - - if (validationErrors.Count > 0) - { - return new FormSubmissionResult - { - ErrorMessage = "Resolve validation issues before submitting.", - Errors = validationErrors - }; - } - - IsMutating = true; - try - { - var updatedSkill = await RequestAsync( - "PUT", - $"/api/skills/{EditingSkillId.Value}", - new UpdateSkillRequest(model.Name.Trim(), model.DiceRollDefinition.Trim(), model.WildDice, model.AllowFumble)); - - SelectedSkillId = updatedSkill.Id; - CloseSkillModals(); - await RefreshCampaignScopeAsync(); - SetStatus("Skill updated.", false); - return new FormSubmissionResult(); - } - catch (ApiRequestException ex) - { - return new FormSubmissionResult { ErrorMessage = ex.Message }; - } - finally - { - IsMutating = false; - } + SelectedSkillId = skillId; + CloseSkillModals(); + await RefreshCampaignScopeAsync(); + SetStatus("Skill updated.", false); } private async Task RollSelectedSkillAsync() diff --git a/RpgRoller/Components/Pages/Home.State.cs b/RpgRoller/Components/Pages/Home.State.cs index 90ab507..8165f96 100644 --- a/RpgRoller/Components/Pages/Home.State.cs +++ b/RpgRoller/Components/Pages/Home.State.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; -using System.Text.Json; using Microsoft.AspNetCore.Components; using Microsoft.JSInterop; using RpgRoller.Contracts; @@ -15,7 +14,6 @@ public partial class Home private const string ScreenSessionKey = "screen"; private const string CampaignSessionKey = "campaign"; private const string MobilePanelSessionKey = "play-panel"; - private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); private UserSummary? User { get; set; } private Guid? ActiveCharacterId { get; set; } diff --git a/RpgRoller/Components/Pages/Home.Validation.cs b/RpgRoller/Components/Pages/Home.Validation.cs index 0e02020..c1b7b09 100644 --- a/RpgRoller/Components/Pages/Home.Validation.cs +++ b/RpgRoller/Components/Pages/Home.Validation.cs @@ -2,22 +2,4 @@ namespace RpgRoller.Components.Pages; public partial class Home { - private static void AddRequiredError(Dictionary errors, string field, string? value, string message) - { - if (string.IsNullOrWhiteSpace(value)) - { - errors[field] = message; - } - } - - private static bool TryParseGuid(string? rawValue, Dictionary errors, string field, string message, out Guid parsed) - { - if (Guid.TryParse(rawValue, out parsed)) - { - return true; - } - - errors[field] = message; - return false; - } } diff --git a/RpgRoller/Components/Pages/Home.razor b/RpgRoller/Components/Pages/Home.razor index 1d4681c..08e0540 100644 --- a/RpgRoller/Components/Pages/Home.razor +++ b/RpgRoller/Components/Pages/Home.razor @@ -27,11 +27,9 @@ case HomeViewMode.Anonymous: + LoggedIn="OnLoggedInAsync" /> break; case HomeViewMode.Workspace: @@ -119,7 +117,7 @@ OwnerLabel="OwnerLabel" CanEditCharacter="CanEditCharacter" CampaignSelectionChanged="OnCampaignSelectionChangedAsync" - CreateCampaignSubmitted="CreateCampaignAsync" + CampaignCreated="OnCampaignCreatedAsync" CreateCharacterRequested="OpenCreateCharacterModal" EditCharacterRequested="OpenEditCharacterModal" /> } @@ -136,9 +134,10 @@ CampaignInputId="character-create-campaign" InitialModel="CreateCharacterInitialModel" FormVersion="CreateCharacterFormVersion" + EditingCharacterId="null" Campaigns="Campaigns" IsMutating="IsMutating" - SubmitRequested="CreateCharacterAsync" + CharacterSaved="OnCharacterCreatedAsync" CancelRequested="CloseCharacterModals" /> diff --git a/RpgRoller/Components/Pages/HomeControls/AuthSection.razor b/RpgRoller/Components/Pages/HomeControls/AuthSection.razor index 9acc268..e4dee8c 100644 --- a/RpgRoller/Components/Pages/HomeControls/AuthSection.razor +++ b/RpgRoller/Components/Pages/HomeControls/AuthSection.razor @@ -1,6 +1,9 @@ @using System.Diagnostics.CodeAnalysis +@using RpgRoller.Components @using RpgRoller.Components.Pages +@using RpgRoller.Contracts @attribute [ExcludeFromCodeCoverage] +@inject RpgRollerApiClient ApiClient

RpgRoller

@@ -35,7 +38,7 @@ {

@registerPasswordError

} - + @@ -58,7 +61,7 @@ {

@loginPasswordError

} - + @@ -67,9 +70,7 @@ @code { private FormState RegisterState { get; } = new(); private FormState LoginState { get; } = new(); - - [Parameter] - public bool IsMutating { get; set; } + private bool IsSubmitting { get; set; } [Parameter] public string? StatusMessage { get; set; } @@ -78,26 +79,59 @@ public bool StatusIsError { get; set; } [Parameter] - public Func> RegisterSubmitted { get; set; } = _ => Task.FromResult(new FormSubmissionResult()); - - [Parameter] - public Func> LoginSubmitted { get; set; } = _ => Task.FromResult(new FormSubmissionResult()); + public EventCallback LoggedIn { get; set; } private async Task SubmitRegisterAsync() { RegisterState.ResetValidation(); - var result = await RegisterSubmitted.Invoke(new RegisterFormModel + var model = RegisterState.Model; + if (string.IsNullOrWhiteSpace(model.Username)) { - Username = RegisterState.Model.Username, - DisplayName = RegisterState.Model.DisplayName, - Password = RegisterState.Model.Password - }); + RegisterState.Errors["username"] = "Username is required."; + } - ApplyResult(RegisterState, result); - if (result.IsSuccess) + if (string.IsNullOrWhiteSpace(model.DisplayName)) { - RegisterState.Model.Password = string.Empty; + RegisterState.Errors["displayName"] = "Display name is required."; + } + + if (string.IsNullOrWhiteSpace(model.Password) || model.Password.Length < 8) + { + RegisterState.Errors["password"] = "Password must be at least 8 characters."; + } + + if (RegisterState.Errors.Count > 0) + { + RegisterState.ErrorMessage = "Resolve validation issues before submitting."; + return; + } + + IsSubmitting = true; + try + { + _ = await ApiClient.RequestAsync( + "POST", + "/api/auth/register", + new RegisterRequest(model.Username.Trim(), model.Password, model.DisplayName.Trim())); + + model.Password = string.Empty; + RegisterState.ErrorMessage = "Registration successful. You can log in now."; + } + catch (ApiRequestException ex) + { + if (ex.Message.Contains("already taken", StringComparison.OrdinalIgnoreCase)) + { + RegisterState.Errors["username"] = "Username is already taken. Choose another one."; + } + else + { + RegisterState.ErrorMessage = ex.Message; + } + } + finally + { + IsSubmitting = false; } } @@ -105,28 +139,41 @@ { LoginState.ResetValidation(); - var result = await LoginSubmitted.Invoke(new LoginFormModel + var model = LoginState.Model; + if (string.IsNullOrWhiteSpace(model.Username)) { - Username = LoginState.Model.Username, - Password = LoginState.Model.Password - }); - - ApplyResult(LoginState, result); - if (result.IsSuccess) - { - LoginState.Model.Password = string.Empty; - } - } - - private static void ApplyResult(FormState state, FormSubmissionResult result) - where TModel : new() - { - state.Errors.Clear(); - foreach (var (key, value) in result.Errors) - { - state.Errors[key] = value; + LoginState.Errors["username"] = "Username is required."; } - state.ErrorMessage = result.ErrorMessage; + if (string.IsNullOrWhiteSpace(model.Password)) + { + LoginState.Errors["password"] = "Password is required."; + } + + if (LoginState.Errors.Count > 0) + { + LoginState.ErrorMessage = "Resolve validation issues before submitting."; + return; + } + + IsSubmitting = true; + try + { + _ = await ApiClient.RequestAsync( + "POST", + "/api/auth/login", + new LoginRequest(model.Username.Trim(), model.Password)); + + model.Password = string.Empty; + await LoggedIn.InvokeAsync(); + } + catch (ApiRequestException ex) + { + LoginState.ErrorMessage = ex.Message; + } + finally + { + IsSubmitting = false; + } } } diff --git a/RpgRoller/Components/Pages/HomeControls/CampaignManagementPanel.razor b/RpgRoller/Components/Pages/HomeControls/CampaignManagementPanel.razor index bfb51f1..d91df31 100644 --- a/RpgRoller/Components/Pages/HomeControls/CampaignManagementPanel.razor +++ b/RpgRoller/Components/Pages/HomeControls/CampaignManagementPanel.razor @@ -1,7 +1,9 @@ @using System.Diagnostics.CodeAnalysis +@using RpgRoller.Components @using RpgRoller.Components.Pages @using RpgRoller.Contracts @attribute [ExcludeFromCodeCoverage] +@inject RpgRollerApiClient ApiClient
@@ -48,7 +50,7 @@ {

@campaignRulesetError

} - +
@@ -70,7 +72,7 @@

Character Management

- +
@if (SelectedCampaign is null) { @@ -88,7 +90,7 @@
  • @character.Name

    Owner: @OwnerLabel(character.OwnerUserId)

    - +
  • } @@ -99,6 +101,7 @@ @code { private FormState CampaignState { get; } = new(); + private bool IsCreatingCampaign { get; set; } [Parameter] public IReadOnlyList Campaigns { get; set; } = []; @@ -128,7 +131,7 @@ public EventCallback CampaignSelectionChanged { get; set; } [Parameter] - public Func> CreateCampaignSubmitted { get; set; } = _ => Task.FromResult(new FormSubmissionResult()); + public EventCallback CampaignCreated { get; set; } [Parameter] public EventCallback CreateCharacterRequested { get; set; } @@ -148,22 +151,40 @@ { CampaignState.ResetValidation(); - var result = await CreateCampaignSubmitted.Invoke(new CampaignFormModel + if (string.IsNullOrWhiteSpace(CampaignState.Model.Name)) { - Name = CampaignState.Model.Name, - RulesetId = CampaignState.Model.RulesetId - }); - - CampaignState.Errors.Clear(); - foreach (var (key, value) in result.Errors) - { - CampaignState.Errors[key] = value; + CampaignState.Errors["name"] = "Campaign name is required."; } - CampaignState.ErrorMessage = result.ErrorMessage; - if (result.IsSuccess) + if (string.IsNullOrWhiteSpace(CampaignState.Model.RulesetId)) { + CampaignState.Errors["rulesetId"] = "Ruleset is required."; + } + + if (CampaignState.Errors.Count > 0) + { + CampaignState.ErrorMessage = "Resolve validation issues before submitting."; + return; + } + + IsCreatingCampaign = true; + try + { + var campaign = await ApiClient.RequestAsync( + "POST", + "/api/campaigns", + new CreateCampaignRequest(CampaignState.Model.Name.Trim(), CampaignState.Model.RulesetId)); + CampaignState.Model.Name = string.Empty; + await CampaignCreated.InvokeAsync(campaign.Id); + } + catch (ApiRequestException ex) + { + CampaignState.ErrorMessage = ex.Message; + } + finally + { + IsCreatingCampaign = false; } } } diff --git a/RpgRoller/Components/Pages/HomeControls/CharacterFormModal.razor b/RpgRoller/Components/Pages/HomeControls/CharacterFormModal.razor index de95319..873d568 100644 --- a/RpgRoller/Components/Pages/HomeControls/CharacterFormModal.razor +++ b/RpgRoller/Components/Pages/HomeControls/CharacterFormModal.razor @@ -1,7 +1,9 @@ @using System.Diagnostics.CodeAnalysis +@using RpgRoller.Components @using RpgRoller.Components.Pages @using RpgRoller.Contracts @attribute [ExcludeFromCodeCoverage] +@inject RpgRollerApiClient ApiClient @if (Visible) { @@ -32,7 +34,7 @@

    @campaignError

    }
    - +
    @@ -43,6 +45,7 @@ @code { private FormState FormState { get; } = new(); private int AppliedFormVersion { get; set; } = -1; + private bool IsSubmitting { get; set; } [Parameter] public bool Visible { get; set; } @@ -65,6 +68,9 @@ [Parameter] public int FormVersion { get; set; } + [Parameter] + public Guid? EditingCharacterId { get; set; } + [Parameter] public IReadOnlyList Campaigns { get; set; } = []; @@ -72,7 +78,7 @@ public bool IsMutating { get; set; } [Parameter] - public Func> SubmitRequested { get; set; } = _ => Task.FromResult(new FormSubmissionResult()); + public EventCallback CharacterSaved { get; set; } [Parameter] public EventCallback CancelRequested { get; set; } @@ -94,17 +100,50 @@ { FormState.ResetValidation(); - var result = await SubmitRequested.Invoke(new CharacterFormModel + if (string.IsNullOrWhiteSpace(FormState.Model.Name)) { - Name = FormState.Model.Name, - CampaignId = FormState.Model.CampaignId - }); - - foreach (var (key, value) in result.Errors) - { - FormState.Errors[key] = value; + FormState.Errors["name"] = "Character name is required."; } - FormState.ErrorMessage = result.ErrorMessage; + if (!Guid.TryParse(FormState.Model.CampaignId, out var campaignId)) + { + FormState.Errors["campaignId"] = "Campaign is required."; + } + + if (FormState.Errors.Count > 0) + { + FormState.ErrorMessage = "Resolve validation issues before submitting."; + return; + } + + IsSubmitting = true; + try + { + CharacterSummary character; + if (EditingCharacterId.HasValue) + { + character = await ApiClient.RequestAsync( + "PUT", + $"/api/characters/{EditingCharacterId.Value}", + new UpdateCharacterRequest(FormState.Model.Name.Trim(), campaignId)); + } + else + { + character = await ApiClient.RequestAsync( + "POST", + "/api/characters", + new CreateCharacterRequest(FormState.Model.Name.Trim(), campaignId)); + } + + await CharacterSaved.InvokeAsync(character.CampaignId); + } + catch (ApiRequestException ex) + { + FormState.ErrorMessage = ex.Message; + } + finally + { + IsSubmitting = false; + } } } diff --git a/RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor b/RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor index a4a920c..207ed69 100644 --- a/RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor +++ b/RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor @@ -1,6 +1,9 @@ @using System.Diagnostics.CodeAnalysis +@using RpgRoller.Components @using RpgRoller.Components.Pages +@using RpgRoller.Contracts @attribute [ExcludeFromCodeCoverage] +@inject RpgRollerApiClient ApiClient @if (Visible) { @@ -36,7 +39,7 @@ }
    - +
    @@ -47,6 +50,7 @@ @code { private FormState FormState { get; } = new(); private int AppliedFormVersion { get; set; } = -1; + private bool IsSubmitting { get; set; } [Parameter] public bool Visible { get; set; } @@ -78,11 +82,17 @@ [Parameter] public int FormVersion { get; set; } + [Parameter] + public Guid? SelectedCharacterId { get; set; } + + [Parameter] + public Guid? EditingSkillId { get; set; } + [Parameter] public bool IsMutating { get; set; } [Parameter] - public Func> SubmitRequested { get; set; } = _ => Task.FromResult(new FormSubmissionResult()); + public EventCallback SkillSaved { get; set; } [Parameter] public EventCallback CancelRequested { get; set; } @@ -106,19 +116,69 @@ { FormState.ResetValidation(); - var result = await SubmitRequested.Invoke(new SkillFormModel + if (string.IsNullOrWhiteSpace(FormState.Model.Name)) { - Name = FormState.Model.Name, - DiceRollDefinition = FormState.Model.DiceRollDefinition, - WildDice = FormState.Model.WildDice, - AllowFumble = FormState.Model.AllowFumble - }); - - foreach (var (key, value) in result.Errors) - { - FormState.Errors[key] = value; + FormState.Errors["name"] = "Skill name is required."; } - FormState.ErrorMessage = result.ErrorMessage; + if (string.IsNullOrWhiteSpace(FormState.Model.DiceRollDefinition)) + { + FormState.Errors["diceRollDefinition"] = "Expression is required."; + } + + if (IsD6 && FormState.Model.WildDice < 1) + { + FormState.Errors["wildDice"] = "D6 skills require at least one wild die."; + } + + if (FormState.Errors.Count > 0) + { + FormState.ErrorMessage = "Resolve validation issues before submitting."; + return; + } + + IsSubmitting = true; + try + { + SkillSummary skill; + if (EditingSkillId.HasValue) + { + skill = await ApiClient.RequestAsync( + "PUT", + $"/api/skills/{EditingSkillId.Value}", + new UpdateSkillRequest( + FormState.Model.Name.Trim(), + FormState.Model.DiceRollDefinition.Trim(), + FormState.Model.WildDice, + FormState.Model.AllowFumble)); + } + else + { + if (!SelectedCharacterId.HasValue) + { + FormState.ErrorMessage = "Select a character first."; + return; + } + + skill = await ApiClient.RequestAsync( + "POST", + $"/api/characters/{SelectedCharacterId.Value}/skills", + new CreateSkillRequest( + FormState.Model.Name.Trim(), + FormState.Model.DiceRollDefinition.Trim(), + FormState.Model.WildDice, + FormState.Model.AllowFumble)); + } + + await SkillSaved.InvokeAsync(skill.Id); + } + catch (ApiRequestException ex) + { + FormState.ErrorMessage = ex.Message; + } + finally + { + IsSubmitting = false; + } } } diff --git a/RpgRoller/Components/RpgRollerApiClient.cs b/RpgRoller/Components/RpgRollerApiClient.cs new file mode 100644 index 0000000..1d9a8ae --- /dev/null +++ b/RpgRoller/Components/RpgRollerApiClient.cs @@ -0,0 +1,59 @@ +using System.Text.Json; +using Microsoft.JSInterop; + +namespace RpgRoller.Components; + +public sealed class RpgRollerApiClient +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + private readonly IJSRuntime m_Js; + + public RpgRollerApiClient(IJSRuntime js) + { + m_Js = js; + } + + public async Task RequestAsync(string method, string path, object? payload = null) + { + var response = await m_Js.InvokeAsync("rpgRollerApi.request", method, path, payload); + if (!response.Ok) + { + throw new ApiRequestException(response.Status, response.Error ?? "Request failed."); + } + + if (response.Data.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null) + { + return default!; + } + + return response.Data.Deserialize(JsonOptions)!; + } + + public async Task RequestWithoutPayloadAsync(string method, string path) + { + var response = await m_Js.InvokeAsync("rpgRollerApi.request", method, path, null); + if (!response.Ok) + { + throw new ApiRequestException(response.Status, response.Error ?? "Request failed."); + } + } + + private sealed class JsApiResponse + { + public bool Ok { get; set; } + public int Status { get; set; } + public string? Error { get; set; } + public JsonElement Data { get; set; } + } +} + +public sealed class ApiRequestException : Exception +{ + public ApiRequestException(int statusCode, string message) + : base(message) + { + StatusCode = statusCode; + } + + public int StatusCode { get; } +} diff --git a/RpgRoller/Program.cs b/RpgRoller/Program.cs index e796027..9d74663 100644 --- a/RpgRoller/Program.cs +++ b/RpgRoller/Program.cs @@ -5,6 +5,7 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.AddRpgRollerCore(builder.Configuration, builder.Environment); builder.Services.AddRazorComponents() .AddInteractiveServerComponents(); +builder.Services.AddScoped(); var app = builder.Build(); app.InitializeRpgRollerState(); diff --git a/TECH.md b/TECH.md index d64dd42..743e4fe 100644 --- a/TECH.md +++ b/TECH.md @@ -94,7 +94,8 @@ This pattern is a strong baseline for low to medium scale and should be the defa - Blazor component tree rooted in `Components/App.razor` and `Components/Pages/Home.razor`. - Home page logic split by concern with partials (`Home.State/Auth/Campaign/Character/Skill/Lifecycle/Realtime/Api/Presentation/Validation.cs`) to keep churn localized. - Form UX state uses reusable `FormState` containers in leaf controls (`HomeControls/*`) rather than parallel form/error/message property sets in `Home`. -- `Home` workflows return `FormSubmissionResult` so controls can render field and summary errors without parent-owned form wiring. +- Concern controls execute their own auth/campaign/character/skill mutation workflows and notify `Home` only for shared-state refresh/orchestration. +- Shared browser API interop is centralized in `RpgRollerApiClient` and reused by `Home` plus concern controls. - Browser API calls and SSE are handled via `wwwroot/js/rpgroller-api.js` interop. - UI state is maintained server-side per circuit with session/tab persistence for campaign + screen selection. - SSE-driven campaign refresh with reconnect backoff and explicit offline/manual-refresh fallback.