diff --git a/FAQ.md b/FAQ.md index cb3145a..dd6fc7e 100644 --- a/FAQ.md +++ b/FAQ.md @@ -71,3 +71,7 @@ There is no separate activate button in Play. The selected character in the char ## Where did the Home page logic move after the refactor? `Home.razor` now focuses on composition and delegates behavior to concern-based code-behind partials (`Home.Auth.cs`, `Home.Campaign.cs`, `Home.Character.cs`, `Home.Skill.cs`, etc.) plus dedicated UI controls under `Components/Pages/HomeControls/`. + +## 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. diff --git a/README.md b/README.md index 555a16a..f338310 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ 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 - `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.Auth.cs b/RpgRoller/Components/Pages/Home.Auth.cs index e612df1..061f478 100644 --- a/RpgRoller/Components/Pages/Home.Auth.cs +++ b/RpgRoller/Components/Pages/Home.Auth.cs @@ -4,23 +4,25 @@ namespace RpgRoller.Components.Pages; public partial class Home { - private async Task RegisterAsync() + private async Task RegisterAsync(RegisterFormModel model) { - RegisterState.ResetValidation(); - var model = RegisterState.Model; + var validationErrors = new Dictionary(); - AddRequiredError(RegisterState.Errors, "username", model.Username, "Username is required."); - AddRequiredError(RegisterState.Errors, "displayName", model.DisplayName, "Display name is required."); + 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) { - RegisterState.Errors["password"] = "Password must be at least 8 characters."; + validationErrors["password"] = "Password must be at least 8 characters."; } - if (RegisterState.Errors.Count > 0) + if (validationErrors.Count > 0) { - RegisterState.ErrorMessage = "Resolve validation issues before submitting."; - return; + return new FormSubmissionResult + { + ErrorMessage = "Resolve validation issues before submitting.", + Errors = validationErrors + }; } IsMutating = true; @@ -31,18 +33,23 @@ public partial class Home "/api/auth/register", new RegisterRequest(model.Username.Trim(), model.Password, model.DisplayName.Trim())); - model.Password = string.Empty; SetStatus("Registration successful. You can log in now.", false); + return new FormSubmissionResult(); } catch (ApiRequestException ex) { if (ex.Message.Contains("already taken", StringComparison.OrdinalIgnoreCase)) { - RegisterState.Errors["username"] = "Username is already taken. Choose another one."; - return; + return new FormSubmissionResult + { + Errors = new Dictionary + { + ["username"] = "Username is already taken. Choose another one." + } + }; } - RegisterState.ErrorMessage = ex.Message; + return new FormSubmissionResult { ErrorMessage = ex.Message }; } finally { @@ -50,18 +57,20 @@ public partial class Home } } - private async Task LoginAsync() + private async Task LoginAsync(LoginFormModel model) { - LoginState.ResetValidation(); - var model = LoginState.Model; + var validationErrors = new Dictionary(); - AddRequiredError(LoginState.Errors, "username", model.Username, "Username is required."); - AddRequiredError(LoginState.Errors, "password", model.Password, "Password is required."); + AddRequiredError(validationErrors, "username", model.Username, "Username is required."); + AddRequiredError(validationErrors, "password", model.Password, "Password is required."); - if (LoginState.Errors.Count > 0) + if (validationErrors.Count > 0) { - LoginState.ErrorMessage = "Resolve validation issues before submitting."; - return; + return new FormSubmissionResult + { + ErrorMessage = "Resolve validation issues before submitting.", + Errors = validationErrors + }; } IsMutating = true; @@ -72,13 +81,13 @@ public partial class Home "/api/auth/login", new LoginRequest(model.Username.Trim(), model.Password)); - model.Password = string.Empty; await ReloadAuthenticatedSessionAsync(null); SetStatus("Logged in.", false); + return new FormSubmissionResult(); } catch (ApiRequestException ex) { - LoginState.ErrorMessage = ex.Message; + return new FormSubmissionResult { ErrorMessage = ex.Message }; } finally { diff --git a/RpgRoller/Components/Pages/Home.Campaign.cs b/RpgRoller/Components/Pages/Home.Campaign.cs index 7ec5ea0..cf8f1f1 100644 --- a/RpgRoller/Components/Pages/Home.Campaign.cs +++ b/RpgRoller/Components/Pages/Home.Campaign.cs @@ -51,18 +51,20 @@ public partial class Home await SyncStateEventsAsync(); } - private async Task CreateCampaignAsync() + private async Task CreateCampaignAsync(CampaignFormModel model) { - CampaignState.ResetValidation(); - var model = CampaignState.Model; + var validationErrors = new Dictionary(); - AddRequiredError(CampaignState.Errors, "name", model.Name, "Campaign name is required."); - AddRequiredError(CampaignState.Errors, "rulesetId", model.RulesetId, "Ruleset is required."); + AddRequiredError(validationErrors, "name", model.Name, "Campaign name is required."); + AddRequiredError(validationErrors, "rulesetId", model.RulesetId, "Ruleset is required."); - if (CampaignState.Errors.Count > 0) + if (validationErrors.Count > 0) { - CampaignState.ErrorMessage = "Resolve validation issues before submitting."; - return; + return new FormSubmissionResult + { + ErrorMessage = "Resolve validation issues before submitting.", + Errors = validationErrors + }; } IsMutating = true; @@ -73,15 +75,15 @@ public partial class Home "/api/campaigns", new CreateCampaignRequest(model.Name.Trim(), model.RulesetId)); - model.Name = string.Empty; await ReloadCampaignsAsync(campaign.Id); await RefreshCampaignScopeAsync(); await SyncStateEventsAsync(); SetStatus("Campaign created.", false); + return new FormSubmissionResult(); } catch (ApiRequestException ex) { - CampaignState.ErrorMessage = ex.Message; + return new FormSubmissionResult { ErrorMessage = ex.Message }; } finally { diff --git a/RpgRoller/Components/Pages/Home.Character.cs b/RpgRoller/Components/Pages/Home.Character.cs index 05a5d45..895c47e 100644 --- a/RpgRoller/Components/Pages/Home.Character.cs +++ b/RpgRoller/Components/Pages/Home.Character.cs @@ -6,10 +6,12 @@ public partial class Home { private void OpenCreateCharacterModal() { - var model = CreateCharacterState.Model; - model.Name = string.Empty; - model.CampaignId = SelectedCampaignId?.ToString() ?? string.Empty; - CreateCharacterState.ResetValidation(); + CreateCharacterInitialModel = new CharacterFormModel + { + Name = string.Empty, + CampaignId = SelectedCampaignId?.ToString() ?? string.Empty + }; + CreateCharacterFormVersion++; ShowCreateCharacterModal = true; } @@ -17,11 +19,12 @@ public partial class Home { EditingCharacterId = character.Id; - var model = EditCharacterState.Model; - model.Name = character.Name; - model.CampaignId = character.CampaignId.ToString(); - - EditCharacterState.ResetValidation(); + EditCharacterInitialModel = new CharacterFormModel + { + Name = character.Name, + CampaignId = character.CampaignId.ToString() + }; + EditCharacterFormVersion++; ShowEditCharacterModal = true; } @@ -32,23 +35,25 @@ public partial class Home EditingCharacterId = null; } - private async Task CreateCharacterAsync() + private async Task CreateCharacterAsync(CharacterFormModel model) { - CreateCharacterState.ResetValidation(); - var model = CreateCharacterState.Model; + var validationErrors = new Dictionary(); - AddRequiredError(CreateCharacterState.Errors, "name", model.Name, "Character name is required."); + AddRequiredError(validationErrors, "name", model.Name, "Character name is required."); var hasCampaignId = TryParseGuid( model.CampaignId, - CreateCharacterState.Errors, + validationErrors, "campaignId", "Campaign is required.", out var campaignId); - if (CreateCharacterState.Errors.Count > 0 || !hasCampaignId) + if (validationErrors.Count > 0 || !hasCampaignId) { - CreateCharacterState.ErrorMessage = "Resolve validation issues before submitting."; - return; + return new FormSubmissionResult + { + ErrorMessage = "Resolve validation issues before submitting.", + Errors = validationErrors + }; } IsMutating = true; @@ -64,10 +69,11 @@ public partial class Home await RefreshCampaignScopeAsync(); await SyncStateEventsAsync(); SetStatus("Character created.", false); + return new FormSubmissionResult(); } catch (ApiRequestException ex) { - CreateCharacterState.ErrorMessage = ex.Message; + return new FormSubmissionResult { ErrorMessage = ex.Message }; } finally { @@ -75,29 +81,29 @@ public partial class Home } } - private async Task UpdateCharacterAsync() + private async Task UpdateCharacterAsync(CharacterFormModel model) { - EditCharacterState.ResetValidation(); - if (!EditingCharacterId.HasValue) { - EditCharacterState.ErrorMessage = "No character selected."; - return; + return new FormSubmissionResult { ErrorMessage = "No character selected." }; } - var model = EditCharacterState.Model; - AddRequiredError(EditCharacterState.Errors, "name", model.Name, "Character name is required."); + var validationErrors = new Dictionary(); + AddRequiredError(validationErrors, "name", model.Name, "Character name is required."); var hasCampaignId = TryParseGuid( model.CampaignId, - EditCharacterState.Errors, + validationErrors, "campaignId", "Campaign is required.", out var campaignId); - if (EditCharacterState.Errors.Count > 0 || !hasCampaignId) + if (validationErrors.Count > 0 || !hasCampaignId) { - EditCharacterState.ErrorMessage = "Resolve validation issues before submitting."; - return; + return new FormSubmissionResult + { + ErrorMessage = "Resolve validation issues before submitting.", + Errors = validationErrors + }; } IsMutating = true; @@ -113,10 +119,11 @@ public partial class Home await RefreshCampaignScopeAsync(); await SyncStateEventsAsync(); SetStatus("Character updated.", false); + return new FormSubmissionResult(); } catch (ApiRequestException ex) { - EditCharacterState.ErrorMessage = ex.Message; + return new FormSubmissionResult { ErrorMessage = ex.Message }; } finally { diff --git a/RpgRoller/Components/Pages/Home.Lifecycle.cs b/RpgRoller/Components/Pages/Home.Lifecycle.cs index c140cba..8c0c933 100644 --- a/RpgRoller/Components/Pages/Home.Lifecycle.cs +++ b/RpgRoller/Components/Pages/Home.Lifecycle.cs @@ -80,10 +80,6 @@ public partial class Home try { Rulesets = (await RequestAsync>("GET", "/api/rulesets")).ToList(); - if (Rulesets.Count > 0 && string.IsNullOrWhiteSpace(CampaignState.Model.RulesetId)) - { - CampaignState.Model.RulesetId = Rulesets[0].Id; - } } catch (ApiRequestException ex) { diff --git a/RpgRoller/Components/Pages/Home.Models.cs b/RpgRoller/Components/Pages/Home.Models.cs index ab327e8..d186621 100644 --- a/RpgRoller/Components/Pages/Home.Models.cs +++ b/RpgRoller/Components/Pages/Home.Models.cs @@ -14,6 +14,13 @@ 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.Presentation.cs b/RpgRoller/Components/Pages/Home.Presentation.cs index 557118d..354f375 100644 --- a/RpgRoller/Components/Pages/Home.Presentation.cs +++ b/RpgRoller/Components/Pages/Home.Presentation.cs @@ -155,6 +155,14 @@ public partial class Home ShowEditCharacterModal = false; ShowCreateSkillModal = false; ShowEditSkillModal = false; + CreateCharacterInitialModel = new(); + EditCharacterInitialModel = new(); + CreateSkillInitialModel = new(); + EditSkillInitialModel = new(); + CreateCharacterFormVersion = 0; + EditCharacterFormVersion = 0; + CreateSkillFormVersion = 0; + EditSkillFormVersion = 0; } private void SetStatus(string message, bool isError) diff --git a/RpgRoller/Components/Pages/Home.Skill.cs b/RpgRoller/Components/Pages/Home.Skill.cs index 562c337..f4bd385 100644 --- a/RpgRoller/Components/Pages/Home.Skill.cs +++ b/RpgRoller/Components/Pages/Home.Skill.cs @@ -6,13 +6,14 @@ public partial class Home { private void OpenCreateSkillModal() { - var model = CreateSkillState.Model; - model.Name = string.Empty; - model.DiceRollDefinition = string.Empty; - model.WildDice = IsSelectedCampaignD6 ? 1 : 0; - model.AllowFumble = IsSelectedCampaignD6; - - CreateSkillState.ResetValidation(); + CreateSkillInitialModel = new SkillFormModel + { + Name = string.Empty, + DiceRollDefinition = string.Empty, + WildDice = IsSelectedCampaignD6 ? 1 : 0, + AllowFumble = IsSelectedCampaignD6 + }; + CreateSkillFormVersion++; ShowCreateSkillModal = true; } @@ -25,13 +26,14 @@ public partial class Home EditingSkillId = SelectedSkill.Id; - var model = EditSkillState.Model; - model.Name = SelectedSkill.Name; - model.DiceRollDefinition = SelectedSkill.DiceRollDefinition; - model.WildDice = SelectedSkill.WildDice; - model.AllowFumble = SelectedSkill.AllowFumble; - - EditSkillState.ResetValidation(); + EditSkillInitialModel = new SkillFormModel + { + Name = SelectedSkill.Name, + DiceRollDefinition = SelectedSkill.DiceRollDefinition, + WildDice = SelectedSkill.WildDice, + AllowFumble = SelectedSkill.AllowFumble + }; + EditSkillFormVersion++; ShowEditSkillModal = true; } @@ -42,29 +44,29 @@ public partial class Home EditingSkillId = null; } - private async Task CreateSkillAsync() + private async Task CreateSkillAsync(SkillFormModel model) { - CreateSkillState.ResetValidation(); - if (SelectedCharacter is null) { - CreateSkillState.ErrorMessage = "Select a character first."; - return; + return new FormSubmissionResult { ErrorMessage = "Select a character first." }; } - var model = CreateSkillState.Model; - AddRequiredError(CreateSkillState.Errors, "name", model.Name, "Skill name is required."); - AddRequiredError(CreateSkillState.Errors, "diceRollDefinition", model.DiceRollDefinition, "Expression is required."); + 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) { - CreateSkillState.Errors["wildDice"] = "D6 skills require at least one wild die."; + validationErrors["wildDice"] = "D6 skills require at least one wild die."; } - if (CreateSkillState.Errors.Count > 0) + if (validationErrors.Count > 0) { - CreateSkillState.ErrorMessage = "Resolve validation issues before submitting."; - return; + return new FormSubmissionResult + { + ErrorMessage = "Resolve validation issues before submitting.", + Errors = validationErrors + }; } IsMutating = true; @@ -78,10 +80,11 @@ public partial class Home CloseSkillModals(); await RefreshCampaignScopeAsync(); SetStatus("Skill created.", false); + return new FormSubmissionResult(); } catch (ApiRequestException ex) { - CreateSkillState.ErrorMessage = ex.Message; + return new FormSubmissionResult { ErrorMessage = ex.Message }; } finally { @@ -89,29 +92,29 @@ public partial class Home } } - private async Task UpdateSkillAsync() + private async Task UpdateSkillAsync(SkillFormModel model) { - EditSkillState.ResetValidation(); - if (!EditingSkillId.HasValue) { - EditSkillState.ErrorMessage = "No skill selected."; - return; + return new FormSubmissionResult { ErrorMessage = "No skill selected." }; } - var model = EditSkillState.Model; - AddRequiredError(EditSkillState.Errors, "name", model.Name, "Skill name is required."); - AddRequiredError(EditSkillState.Errors, "diceRollDefinition", model.DiceRollDefinition, "Expression is required."); + 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) { - EditSkillState.Errors["wildDice"] = "D6 skills require at least one wild die."; + validationErrors["wildDice"] = "D6 skills require at least one wild die."; } - if (EditSkillState.Errors.Count > 0) + if (validationErrors.Count > 0) { - EditSkillState.ErrorMessage = "Resolve validation issues before submitting."; - return; + return new FormSubmissionResult + { + ErrorMessage = "Resolve validation issues before submitting.", + Errors = validationErrors + }; } IsMutating = true; @@ -126,10 +129,11 @@ public partial class Home CloseSkillModals(); await RefreshCampaignScopeAsync(); SetStatus("Skill updated.", false); + return new FormSubmissionResult(); } catch (ApiRequestException ex) { - EditSkillState.ErrorMessage = ex.Message; + return new FormSubmissionResult { ErrorMessage = ex.Message }; } finally { diff --git a/RpgRoller/Components/Pages/Home.State.cs b/RpgRoller/Components/Pages/Home.State.cs index 607ae8f..90ab507 100644 --- a/RpgRoller/Components/Pages/Home.State.cs +++ b/RpgRoller/Components/Pages/Home.State.cs @@ -17,14 +17,6 @@ public partial class Home private const string MobilePanelSessionKey = "play-panel"; private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); - private FormState RegisterState { get; } = new(); - private FormState LoginState { get; } = new(); - private FormState CampaignState { get; } = new(); - private FormState CreateCharacterState { get; } = new(); - private FormState EditCharacterState { get; } = new(); - private FormState CreateSkillState { get; } = new(); - private FormState EditSkillState { get; } = new(); - private UserSummary? User { get; set; } private Guid? ActiveCharacterId { get; set; } private Guid? SelectedCampaignId { get; set; } @@ -55,6 +47,14 @@ public partial class Home private bool ShowEditSkillModal { get; set; } private Guid? EditingCharacterId { get; set; } private Guid? EditingSkillId { get; set; } + private CharacterFormModel CreateCharacterInitialModel { get; set; } = new(); + private CharacterFormModel EditCharacterInitialModel { get; set; } = new(); + private SkillFormModel CreateSkillInitialModel { get; set; } = new(); + private SkillFormModel EditSkillInitialModel { get; set; } = new(); + private int CreateCharacterFormVersion { get; set; } + private int EditCharacterFormVersion { get; set; } + private int CreateSkillFormVersion { get; set; } + private int EditSkillFormVersion { get; set; } private bool StateRefreshInProgress { get; set; } private bool HasInteractiveRenderStarted { get; set; } private DotNetObjectReference? DotNetRef { get; set; } diff --git a/RpgRoller/Components/Pages/Home.razor b/RpgRoller/Components/Pages/Home.razor index bd7e145..1d4681c 100644 --- a/RpgRoller/Components/Pages/Home.razor +++ b/RpgRoller/Components/Pages/Home.razor @@ -27,8 +27,6 @@ case HomeViewMode.Anonymous: @@ -178,7 +178,8 @@ ExpressionInputId="skill-edit-expression" WildDiceInputId="skill-edit-wild-dice" AllowFumbleInputId="skill-edit-allow-fumble" - FormState="EditSkillState" + InitialModel="EditSkillInitialModel" + FormVersion="EditSkillFormVersion" IsMutating="IsMutating" SubmitRequested="UpdateSkillAsync" CancelRequested="CloseSkillModals" /> diff --git a/RpgRoller/Components/Pages/HomeControls/AuthSection.razor b/RpgRoller/Components/Pages/HomeControls/AuthSection.razor index c5fdda6..9acc268 100644 --- a/RpgRoller/Components/Pages/HomeControls/AuthSection.razor +++ b/RpgRoller/Components/Pages/HomeControls/AuthSection.razor @@ -65,11 +65,8 @@ @code { - [Parameter] - public FormState RegisterState { get; set; } = new(); - - [Parameter] - public FormState LoginState { get; set; } = new(); + private FormState RegisterState { get; } = new(); + private FormState LoginState { get; } = new(); [Parameter] public bool IsMutating { get; set; } @@ -81,18 +78,55 @@ public bool StatusIsError { get; set; } [Parameter] - public EventCallback RegisterSubmitted { get; set; } + public Func> RegisterSubmitted { get; set; } = _ => Task.FromResult(new FormSubmissionResult()); [Parameter] - public EventCallback LoginSubmitted { get; set; } + public Func> LoginSubmitted { get; set; } = _ => Task.FromResult(new FormSubmissionResult()); private async Task SubmitRegisterAsync() { - await RegisterSubmitted.InvokeAsync(); + RegisterState.ResetValidation(); + + var result = await RegisterSubmitted.Invoke(new RegisterFormModel + { + Username = RegisterState.Model.Username, + DisplayName = RegisterState.Model.DisplayName, + Password = RegisterState.Model.Password + }); + + ApplyResult(RegisterState, result); + if (result.IsSuccess) + { + RegisterState.Model.Password = string.Empty; + } } private async Task SubmitLoginAsync() { - await LoginSubmitted.InvokeAsync(); + LoginState.ResetValidation(); + + var result = await LoginSubmitted.Invoke(new LoginFormModel + { + 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; + } + + state.ErrorMessage = result.ErrorMessage; } } diff --git a/RpgRoller/Components/Pages/HomeControls/CampaignManagementPanel.razor b/RpgRoller/Components/Pages/HomeControls/CampaignManagementPanel.razor index 26ae5e1..bfb51f1 100644 --- a/RpgRoller/Components/Pages/HomeControls/CampaignManagementPanel.razor +++ b/RpgRoller/Components/Pages/HomeControls/CampaignManagementPanel.razor @@ -29,7 +29,7 @@ {

@CampaignState.ErrorMessage

} -
+ @if (CampaignState.Errors.TryGetValue("name", out var campaignNameError)) @@ -98,6 +98,8 @@ @code { + private FormState CampaignState { get; } = new(); + [Parameter] public IReadOnlyList Campaigns { get; set; } = []; @@ -110,9 +112,6 @@ [Parameter] public CampaignDetails? SelectedCampaign { get; set; } - [Parameter] - public FormState CampaignState { get; set; } = new(); - [Parameter] public IReadOnlyList Rulesets { get; set; } = []; @@ -129,11 +128,42 @@ public EventCallback CampaignSelectionChanged { get; set; } [Parameter] - public EventCallback CreateCampaignSubmitted { get; set; } + public Func> CreateCampaignSubmitted { get; set; } = _ => Task.FromResult(new FormSubmissionResult()); [Parameter] public EventCallback CreateCharacterRequested { get; set; } [Parameter] public EventCallback EditCharacterRequested { get; set; } + + protected override void OnParametersSet() + { + if (string.IsNullOrWhiteSpace(CampaignState.Model.RulesetId) && Rulesets.Count > 0) + { + CampaignState.Model.RulesetId = Rulesets[0].Id; + } + } + + private async Task SubmitCreateCampaignAsync() + { + CampaignState.ResetValidation(); + + var result = await CreateCampaignSubmitted.Invoke(new CampaignFormModel + { + Name = CampaignState.Model.Name, + RulesetId = CampaignState.Model.RulesetId + }); + + CampaignState.Errors.Clear(); + foreach (var (key, value) in result.Errors) + { + CampaignState.Errors[key] = value; + } + + CampaignState.ErrorMessage = result.ErrorMessage; + if (result.IsSuccess) + { + CampaignState.Model.Name = string.Empty; + } + } } diff --git a/RpgRoller/Components/Pages/HomeControls/CharacterFormModal.razor b/RpgRoller/Components/Pages/HomeControls/CharacterFormModal.razor index 82d42e5..de95319 100644 --- a/RpgRoller/Components/Pages/HomeControls/CharacterFormModal.razor +++ b/RpgRoller/Components/Pages/HomeControls/CharacterFormModal.razor @@ -41,6 +41,9 @@ } @code { + private FormState FormState { get; } = new(); + private int AppliedFormVersion { get; set; } = -1; + [Parameter] public bool Visible { get; set; } @@ -57,7 +60,10 @@ public string CampaignInputId { get; set; } = "character-campaign"; [Parameter] - public FormState FormState { get; set; } = new(); + public CharacterFormModel InitialModel { get; set; } = new(); + + [Parameter] + public int FormVersion { get; set; } [Parameter] public IReadOnlyList Campaigns { get; set; } = []; @@ -66,13 +72,39 @@ public bool IsMutating { get; set; } [Parameter] - public EventCallback SubmitRequested { get; set; } + public Func> SubmitRequested { get; set; } = _ => Task.FromResult(new FormSubmissionResult()); [Parameter] public EventCallback CancelRequested { get; set; } + protected override void OnParametersSet() + { + if (!Visible || FormVersion == AppliedFormVersion) + { + return; + } + + FormState.Model.Name = InitialModel.Name; + FormState.Model.CampaignId = InitialModel.CampaignId; + FormState.ResetValidation(); + AppliedFormVersion = FormVersion; + } + private async Task SubmitAsync() { - await SubmitRequested.InvokeAsync(); + FormState.ResetValidation(); + + var result = await SubmitRequested.Invoke(new CharacterFormModel + { + Name = FormState.Model.Name, + CampaignId = FormState.Model.CampaignId + }); + + foreach (var (key, value) in result.Errors) + { + FormState.Errors[key] = value; + } + + FormState.ErrorMessage = result.ErrorMessage; } } diff --git a/RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor b/RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor index 55f08f7..a4a920c 100644 --- a/RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor +++ b/RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor @@ -45,6 +45,9 @@ } @code { + private FormState FormState { get; } = new(); + private int AppliedFormVersion { get; set; } = -1; + [Parameter] public bool Visible { get; set; } @@ -70,19 +73,52 @@ public string AllowFumbleInputId { get; set; } = "skill-fumble"; [Parameter] - public FormState FormState { get; set; } = new(); + public SkillFormModel InitialModel { get; set; } = new(); + + [Parameter] + public int FormVersion { get; set; } [Parameter] public bool IsMutating { get; set; } [Parameter] - public EventCallback SubmitRequested { get; set; } + public Func> SubmitRequested { get; set; } = _ => Task.FromResult(new FormSubmissionResult()); [Parameter] public EventCallback CancelRequested { get; set; } + protected override void OnParametersSet() + { + if (!Visible || FormVersion == AppliedFormVersion) + { + return; + } + + FormState.Model.Name = InitialModel.Name; + FormState.Model.DiceRollDefinition = InitialModel.DiceRollDefinition; + FormState.Model.WildDice = InitialModel.WildDice; + FormState.Model.AllowFumble = InitialModel.AllowFumble; + FormState.ResetValidation(); + AppliedFormVersion = FormVersion; + } + private async Task SubmitAsync() { - await SubmitRequested.InvokeAsync(); + FormState.ResetValidation(); + + var result = await SubmitRequested.Invoke(new SkillFormModel + { + 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.ErrorMessage = result.ErrorMessage; } } diff --git a/TECH.md b/TECH.md index a55a6c0..d64dd42 100644 --- a/TECH.md +++ b/TECH.md @@ -93,7 +93,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 (`Home.Models.cs`) rather than parallel form/error/message property sets. +- 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. - 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.