Move form state ownership from Home to leaf controls

This commit is contained in:
2026-02-26 09:54:04 +01:00
parent b17490e5ac
commit 4d728f91cf
16 changed files with 315 additions and 143 deletions

4
FAQ.md
View File

@@ -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? ## 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/`. `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.

View File

@@ -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.*.cs`: concern-based partial class split (`State`, `Lifecycle`, `Auth`, `Campaign`, `Character`, `Skill`, `Realtime`, `Api`, `Presentation`, `Validation`)
- `RpgRoller/Components/Pages/Home.Models.cs`: reusable `FormState<TModel>` + page form models - `RpgRoller/Components/Pages/Home.Models.cs`: reusable `FormState<TModel>` + page form models
- `RpgRoller/Components/Pages/HomeControls/`: auth, campaign management, play-screen, and modal controls extracted from `Home.razor` - `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/js/rpgroller-api.js`: browser-side API + SSE + session storage interop for Blazor
- `RpgRoller/wwwroot/styles.css`: responsive UX styling and theme tokens - `RpgRoller/wwwroot/styles.css`: responsive UX styling and theme tokens

View File

@@ -4,23 +4,25 @@ namespace RpgRoller.Components.Pages;
public partial class Home public partial class Home
{ {
private async Task RegisterAsync() private async Task<FormSubmissionResult> RegisterAsync(RegisterFormModel model)
{ {
RegisterState.ResetValidation(); var validationErrors = new Dictionary<string, string>();
var model = RegisterState.Model;
AddRequiredError(RegisterState.Errors, "username", model.Username, "Username is required."); AddRequiredError(validationErrors, "username", model.Username, "Username is required.");
AddRequiredError(RegisterState.Errors, "displayName", model.DisplayName, "Display name is required."); AddRequiredError(validationErrors, "displayName", model.DisplayName, "Display name is required.");
if (string.IsNullOrWhiteSpace(model.Password) || model.Password.Length < 8) 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 new FormSubmissionResult
return; {
ErrorMessage = "Resolve validation issues before submitting.",
Errors = validationErrors
};
} }
IsMutating = true; IsMutating = true;
@@ -31,18 +33,23 @@ public partial class Home
"/api/auth/register", "/api/auth/register",
new RegisterRequest(model.Username.Trim(), model.Password, model.DisplayName.Trim())); new RegisterRequest(model.Username.Trim(), model.Password, model.DisplayName.Trim()));
model.Password = string.Empty;
SetStatus("Registration successful. You can log in now.", false); SetStatus("Registration successful. You can log in now.", false);
return new FormSubmissionResult();
} }
catch (ApiRequestException ex) catch (ApiRequestException ex)
{ {
if (ex.Message.Contains("already taken", StringComparison.OrdinalIgnoreCase)) if (ex.Message.Contains("already taken", StringComparison.OrdinalIgnoreCase))
{ {
RegisterState.Errors["username"] = "Username is already taken. Choose another one."; return new FormSubmissionResult
return; {
Errors = new Dictionary<string, string>
{
["username"] = "Username is already taken. Choose another one."
}
};
} }
RegisterState.ErrorMessage = ex.Message; return new FormSubmissionResult { ErrorMessage = ex.Message };
} }
finally finally
{ {
@@ -50,18 +57,20 @@ public partial class Home
} }
} }
private async Task LoginAsync() private async Task<FormSubmissionResult> LoginAsync(LoginFormModel model)
{ {
LoginState.ResetValidation(); var validationErrors = new Dictionary<string, string>();
var model = LoginState.Model;
AddRequiredError(LoginState.Errors, "username", model.Username, "Username is required."); AddRequiredError(validationErrors, "username", model.Username, "Username is required.");
AddRequiredError(LoginState.Errors, "password", model.Password, "Password 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 new FormSubmissionResult
return; {
ErrorMessage = "Resolve validation issues before submitting.",
Errors = validationErrors
};
} }
IsMutating = true; IsMutating = true;
@@ -72,13 +81,13 @@ public partial class Home
"/api/auth/login", "/api/auth/login",
new LoginRequest(model.Username.Trim(), model.Password)); new LoginRequest(model.Username.Trim(), model.Password));
model.Password = string.Empty;
await ReloadAuthenticatedSessionAsync(null); await ReloadAuthenticatedSessionAsync(null);
SetStatus("Logged in.", false); SetStatus("Logged in.", false);
return new FormSubmissionResult();
} }
catch (ApiRequestException ex) catch (ApiRequestException ex)
{ {
LoginState.ErrorMessage = ex.Message; return new FormSubmissionResult { ErrorMessage = ex.Message };
} }
finally finally
{ {

View File

@@ -51,18 +51,20 @@ public partial class Home
await SyncStateEventsAsync(); await SyncStateEventsAsync();
} }
private async Task CreateCampaignAsync() private async Task<FormSubmissionResult> CreateCampaignAsync(CampaignFormModel model)
{ {
CampaignState.ResetValidation(); var validationErrors = new Dictionary<string, string>();
var model = CampaignState.Model;
AddRequiredError(CampaignState.Errors, "name", model.Name, "Campaign name is required."); AddRequiredError(validationErrors, "name", model.Name, "Campaign name is required.");
AddRequiredError(CampaignState.Errors, "rulesetId", model.RulesetId, "Ruleset 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 new FormSubmissionResult
return; {
ErrorMessage = "Resolve validation issues before submitting.",
Errors = validationErrors
};
} }
IsMutating = true; IsMutating = true;
@@ -73,15 +75,15 @@ public partial class Home
"/api/campaigns", "/api/campaigns",
new CreateCampaignRequest(model.Name.Trim(), model.RulesetId)); new CreateCampaignRequest(model.Name.Trim(), model.RulesetId));
model.Name = string.Empty;
await ReloadCampaignsAsync(campaign.Id); await ReloadCampaignsAsync(campaign.Id);
await RefreshCampaignScopeAsync(); await RefreshCampaignScopeAsync();
await SyncStateEventsAsync(); await SyncStateEventsAsync();
SetStatus("Campaign created.", false); SetStatus("Campaign created.", false);
return new FormSubmissionResult();
} }
catch (ApiRequestException ex) catch (ApiRequestException ex)
{ {
CampaignState.ErrorMessage = ex.Message; return new FormSubmissionResult { ErrorMessage = ex.Message };
} }
finally finally
{ {

View File

@@ -6,10 +6,12 @@ public partial class Home
{ {
private void OpenCreateCharacterModal() private void OpenCreateCharacterModal()
{ {
var model = CreateCharacterState.Model; CreateCharacterInitialModel = new CharacterFormModel
model.Name = string.Empty; {
model.CampaignId = SelectedCampaignId?.ToString() ?? string.Empty; Name = string.Empty,
CreateCharacterState.ResetValidation(); CampaignId = SelectedCampaignId?.ToString() ?? string.Empty
};
CreateCharacterFormVersion++;
ShowCreateCharacterModal = true; ShowCreateCharacterModal = true;
} }
@@ -17,11 +19,12 @@ public partial class Home
{ {
EditingCharacterId = character.Id; EditingCharacterId = character.Id;
var model = EditCharacterState.Model; EditCharacterInitialModel = new CharacterFormModel
model.Name = character.Name; {
model.CampaignId = character.CampaignId.ToString(); Name = character.Name,
CampaignId = character.CampaignId.ToString()
EditCharacterState.ResetValidation(); };
EditCharacterFormVersion++;
ShowEditCharacterModal = true; ShowEditCharacterModal = true;
} }
@@ -32,23 +35,25 @@ public partial class Home
EditingCharacterId = null; EditingCharacterId = null;
} }
private async Task CreateCharacterAsync() private async Task<FormSubmissionResult> CreateCharacterAsync(CharacterFormModel model)
{ {
CreateCharacterState.ResetValidation(); var validationErrors = new Dictionary<string, string>();
var model = CreateCharacterState.Model;
AddRequiredError(CreateCharacterState.Errors, "name", model.Name, "Character name is required."); AddRequiredError(validationErrors, "name", model.Name, "Character name is required.");
var hasCampaignId = TryParseGuid( var hasCampaignId = TryParseGuid(
model.CampaignId, model.CampaignId,
CreateCharacterState.Errors, validationErrors,
"campaignId", "campaignId",
"Campaign is required.", "Campaign is required.",
out var campaignId); out var campaignId);
if (CreateCharacterState.Errors.Count > 0 || !hasCampaignId) if (validationErrors.Count > 0 || !hasCampaignId)
{ {
CreateCharacterState.ErrorMessage = "Resolve validation issues before submitting."; return new FormSubmissionResult
return; {
ErrorMessage = "Resolve validation issues before submitting.",
Errors = validationErrors
};
} }
IsMutating = true; IsMutating = true;
@@ -64,10 +69,11 @@ public partial class Home
await RefreshCampaignScopeAsync(); await RefreshCampaignScopeAsync();
await SyncStateEventsAsync(); await SyncStateEventsAsync();
SetStatus("Character created.", false); SetStatus("Character created.", false);
return new FormSubmissionResult();
} }
catch (ApiRequestException ex) catch (ApiRequestException ex)
{ {
CreateCharacterState.ErrorMessage = ex.Message; return new FormSubmissionResult { ErrorMessage = ex.Message };
} }
finally finally
{ {
@@ -75,29 +81,29 @@ public partial class Home
} }
} }
private async Task UpdateCharacterAsync() private async Task<FormSubmissionResult> UpdateCharacterAsync(CharacterFormModel model)
{ {
EditCharacterState.ResetValidation();
if (!EditingCharacterId.HasValue) if (!EditingCharacterId.HasValue)
{ {
EditCharacterState.ErrorMessage = "No character selected."; return new FormSubmissionResult { ErrorMessage = "No character selected." };
return;
} }
var model = EditCharacterState.Model; var validationErrors = new Dictionary<string, string>();
AddRequiredError(EditCharacterState.Errors, "name", model.Name, "Character name is required."); AddRequiredError(validationErrors, "name", model.Name, "Character name is required.");
var hasCampaignId = TryParseGuid( var hasCampaignId = TryParseGuid(
model.CampaignId, model.CampaignId,
EditCharacterState.Errors, validationErrors,
"campaignId", "campaignId",
"Campaign is required.", "Campaign is required.",
out var campaignId); out var campaignId);
if (EditCharacterState.Errors.Count > 0 || !hasCampaignId) if (validationErrors.Count > 0 || !hasCampaignId)
{ {
EditCharacterState.ErrorMessage = "Resolve validation issues before submitting."; return new FormSubmissionResult
return; {
ErrorMessage = "Resolve validation issues before submitting.",
Errors = validationErrors
};
} }
IsMutating = true; IsMutating = true;
@@ -113,10 +119,11 @@ public partial class Home
await RefreshCampaignScopeAsync(); await RefreshCampaignScopeAsync();
await SyncStateEventsAsync(); await SyncStateEventsAsync();
SetStatus("Character updated.", false); SetStatus("Character updated.", false);
return new FormSubmissionResult();
} }
catch (ApiRequestException ex) catch (ApiRequestException ex)
{ {
EditCharacterState.ErrorMessage = ex.Message; return new FormSubmissionResult { ErrorMessage = ex.Message };
} }
finally finally
{ {

View File

@@ -80,10 +80,6 @@ public partial class Home
try try
{ {
Rulesets = (await RequestAsync<IReadOnlyList<RulesetDefinition>>("GET", "/api/rulesets")).ToList(); Rulesets = (await RequestAsync<IReadOnlyList<RulesetDefinition>>("GET", "/api/rulesets")).ToList();
if (Rulesets.Count > 0 && string.IsNullOrWhiteSpace(CampaignState.Model.RulesetId))
{
CampaignState.Model.RulesetId = Rulesets[0].Id;
}
} }
catch (ApiRequestException ex) catch (ApiRequestException ex)
{ {

View File

@@ -14,6 +14,13 @@ public sealed class FormState<TModel>
} }
} }
public sealed class FormSubmissionResult
{
public Dictionary<string, string> Errors { get; init; } = [];
public string? ErrorMessage { get; init; }
public bool IsSuccess => Errors.Count == 0 && string.IsNullOrWhiteSpace(ErrorMessage);
}
public sealed class RegisterFormModel public sealed class RegisterFormModel
{ {
public string Username { get; set; } = string.Empty; public string Username { get; set; } = string.Empty;

View File

@@ -155,6 +155,14 @@ public partial class Home
ShowEditCharacterModal = false; ShowEditCharacterModal = false;
ShowCreateSkillModal = false; ShowCreateSkillModal = false;
ShowEditSkillModal = 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) private void SetStatus(string message, bool isError)

View File

@@ -6,13 +6,14 @@ public partial class Home
{ {
private void OpenCreateSkillModal() private void OpenCreateSkillModal()
{ {
var model = CreateSkillState.Model; CreateSkillInitialModel = new SkillFormModel
model.Name = string.Empty; {
model.DiceRollDefinition = string.Empty; Name = string.Empty,
model.WildDice = IsSelectedCampaignD6 ? 1 : 0; DiceRollDefinition = string.Empty,
model.AllowFumble = IsSelectedCampaignD6; WildDice = IsSelectedCampaignD6 ? 1 : 0,
AllowFumble = IsSelectedCampaignD6
CreateSkillState.ResetValidation(); };
CreateSkillFormVersion++;
ShowCreateSkillModal = true; ShowCreateSkillModal = true;
} }
@@ -25,13 +26,14 @@ public partial class Home
EditingSkillId = SelectedSkill.Id; EditingSkillId = SelectedSkill.Id;
var model = EditSkillState.Model; EditSkillInitialModel = new SkillFormModel
model.Name = SelectedSkill.Name; {
model.DiceRollDefinition = SelectedSkill.DiceRollDefinition; Name = SelectedSkill.Name,
model.WildDice = SelectedSkill.WildDice; DiceRollDefinition = SelectedSkill.DiceRollDefinition,
model.AllowFumble = SelectedSkill.AllowFumble; WildDice = SelectedSkill.WildDice,
AllowFumble = SelectedSkill.AllowFumble
EditSkillState.ResetValidation(); };
EditSkillFormVersion++;
ShowEditSkillModal = true; ShowEditSkillModal = true;
} }
@@ -42,29 +44,29 @@ public partial class Home
EditingSkillId = null; EditingSkillId = null;
} }
private async Task CreateSkillAsync() private async Task<FormSubmissionResult> CreateSkillAsync(SkillFormModel model)
{ {
CreateSkillState.ResetValidation();
if (SelectedCharacter is null) if (SelectedCharacter is null)
{ {
CreateSkillState.ErrorMessage = "Select a character first."; return new FormSubmissionResult { ErrorMessage = "Select a character first." };
return;
} }
var model = CreateSkillState.Model; var validationErrors = new Dictionary<string, string>();
AddRequiredError(CreateSkillState.Errors, "name", model.Name, "Skill name is required."); AddRequiredError(validationErrors, "name", model.Name, "Skill name is required.");
AddRequiredError(CreateSkillState.Errors, "diceRollDefinition", model.DiceRollDefinition, "Expression is required."); AddRequiredError(validationErrors, "diceRollDefinition", model.DiceRollDefinition, "Expression is required.");
if (IsSelectedCampaignD6 && model.WildDice < 1) 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 new FormSubmissionResult
return; {
ErrorMessage = "Resolve validation issues before submitting.",
Errors = validationErrors
};
} }
IsMutating = true; IsMutating = true;
@@ -78,10 +80,11 @@ public partial class Home
CloseSkillModals(); CloseSkillModals();
await RefreshCampaignScopeAsync(); await RefreshCampaignScopeAsync();
SetStatus("Skill created.", false); SetStatus("Skill created.", false);
return new FormSubmissionResult();
} }
catch (ApiRequestException ex) catch (ApiRequestException ex)
{ {
CreateSkillState.ErrorMessage = ex.Message; return new FormSubmissionResult { ErrorMessage = ex.Message };
} }
finally finally
{ {
@@ -89,29 +92,29 @@ public partial class Home
} }
} }
private async Task UpdateSkillAsync() private async Task<FormSubmissionResult> UpdateSkillAsync(SkillFormModel model)
{ {
EditSkillState.ResetValidation();
if (!EditingSkillId.HasValue) if (!EditingSkillId.HasValue)
{ {
EditSkillState.ErrorMessage = "No skill selected."; return new FormSubmissionResult { ErrorMessage = "No skill selected." };
return;
} }
var model = EditSkillState.Model; var validationErrors = new Dictionary<string, string>();
AddRequiredError(EditSkillState.Errors, "name", model.Name, "Skill name is required."); AddRequiredError(validationErrors, "name", model.Name, "Skill name is required.");
AddRequiredError(EditSkillState.Errors, "diceRollDefinition", model.DiceRollDefinition, "Expression is required."); AddRequiredError(validationErrors, "diceRollDefinition", model.DiceRollDefinition, "Expression is required.");
if (IsSelectedCampaignD6 && model.WildDice < 1) 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 new FormSubmissionResult
return; {
ErrorMessage = "Resolve validation issues before submitting.",
Errors = validationErrors
};
} }
IsMutating = true; IsMutating = true;
@@ -126,10 +129,11 @@ public partial class Home
CloseSkillModals(); CloseSkillModals();
await RefreshCampaignScopeAsync(); await RefreshCampaignScopeAsync();
SetStatus("Skill updated.", false); SetStatus("Skill updated.", false);
return new FormSubmissionResult();
} }
catch (ApiRequestException ex) catch (ApiRequestException ex)
{ {
EditSkillState.ErrorMessage = ex.Message; return new FormSubmissionResult { ErrorMessage = ex.Message };
} }
finally finally
{ {

View File

@@ -17,14 +17,6 @@ public partial class Home
private const string MobilePanelSessionKey = "play-panel"; private const string MobilePanelSessionKey = "play-panel";
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private FormState<RegisterFormModel> RegisterState { get; } = new();
private FormState<LoginFormModel> LoginState { get; } = new();
private FormState<CampaignFormModel> CampaignState { get; } = new();
private FormState<CharacterFormModel> CreateCharacterState { get; } = new();
private FormState<CharacterFormModel> EditCharacterState { get; } = new();
private FormState<SkillFormModel> CreateSkillState { get; } = new();
private FormState<SkillFormModel> EditSkillState { get; } = new();
private UserSummary? User { get; set; } private UserSummary? User { get; set; }
private Guid? ActiveCharacterId { get; set; } private Guid? ActiveCharacterId { get; set; }
private Guid? SelectedCampaignId { get; set; } private Guid? SelectedCampaignId { get; set; }
@@ -55,6 +47,14 @@ public partial class Home
private bool ShowEditSkillModal { get; set; } private bool ShowEditSkillModal { get; set; }
private Guid? EditingCharacterId { get; set; } private Guid? EditingCharacterId { get; set; }
private Guid? EditingSkillId { 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 StateRefreshInProgress { get; set; }
private bool HasInteractiveRenderStarted { get; set; } private bool HasInteractiveRenderStarted { get; set; }
private DotNetObjectReference<Home>? DotNetRef { get; set; } private DotNetObjectReference<Home>? DotNetRef { get; set; }

View File

@@ -27,8 +27,6 @@
case HomeViewMode.Anonymous: case HomeViewMode.Anonymous:
<AuthSection <AuthSection
RegisterState="RegisterState"
LoginState="LoginState"
IsMutating="IsMutating" IsMutating="IsMutating"
StatusMessage="StatusMessage" StatusMessage="StatusMessage"
StatusIsError="StatusIsError" StatusIsError="StatusIsError"
@@ -116,7 +114,6 @@
SelectedCampaignId="SelectedCampaignId" SelectedCampaignId="SelectedCampaignId"
SelectedCampaignName="SelectedCampaignName" SelectedCampaignName="SelectedCampaignName"
SelectedCampaign="SelectedCampaign" SelectedCampaign="SelectedCampaign"
CampaignState="CampaignState"
Rulesets="Rulesets" Rulesets="Rulesets"
IsMutating="IsMutating" IsMutating="IsMutating"
OwnerLabel="OwnerLabel" OwnerLabel="OwnerLabel"
@@ -137,7 +134,8 @@
SubmitLabel="Create Character" SubmitLabel="Create Character"
NameInputId="character-create-name" NameInputId="character-create-name"
CampaignInputId="character-create-campaign" CampaignInputId="character-create-campaign"
FormState="CreateCharacterState" InitialModel="CreateCharacterInitialModel"
FormVersion="CreateCharacterFormVersion"
Campaigns="Campaigns" Campaigns="Campaigns"
IsMutating="IsMutating" IsMutating="IsMutating"
SubmitRequested="CreateCharacterAsync" SubmitRequested="CreateCharacterAsync"
@@ -149,7 +147,8 @@
SubmitLabel="Save Character" SubmitLabel="Save Character"
NameInputId="character-edit-name" NameInputId="character-edit-name"
CampaignInputId="character-edit-campaign" CampaignInputId="character-edit-campaign"
FormState="EditCharacterState" InitialModel="EditCharacterInitialModel"
FormVersion="EditCharacterFormVersion"
Campaigns="Campaigns" Campaigns="Campaigns"
IsMutating="IsMutating" IsMutating="IsMutating"
SubmitRequested="UpdateCharacterAsync" SubmitRequested="UpdateCharacterAsync"
@@ -164,7 +163,8 @@
ExpressionInputId="skill-create-expression" ExpressionInputId="skill-create-expression"
WildDiceInputId="skill-create-wild-dice" WildDiceInputId="skill-create-wild-dice"
AllowFumbleInputId="skill-create-allow-fumble" AllowFumbleInputId="skill-create-allow-fumble"
FormState="CreateSkillState" InitialModel="CreateSkillInitialModel"
FormVersion="CreateSkillFormVersion"
IsMutating="IsMutating" IsMutating="IsMutating"
SubmitRequested="CreateSkillAsync" SubmitRequested="CreateSkillAsync"
CancelRequested="CloseSkillModals" /> CancelRequested="CloseSkillModals" />
@@ -178,7 +178,8 @@
ExpressionInputId="skill-edit-expression" ExpressionInputId="skill-edit-expression"
WildDiceInputId="skill-edit-wild-dice" WildDiceInputId="skill-edit-wild-dice"
AllowFumbleInputId="skill-edit-allow-fumble" AllowFumbleInputId="skill-edit-allow-fumble"
FormState="EditSkillState" InitialModel="EditSkillInitialModel"
FormVersion="EditSkillFormVersion"
IsMutating="IsMutating" IsMutating="IsMutating"
SubmitRequested="UpdateSkillAsync" SubmitRequested="UpdateSkillAsync"
CancelRequested="CloseSkillModals" /> CancelRequested="CloseSkillModals" />

View File

@@ -65,11 +65,8 @@
</main> </main>
@code { @code {
[Parameter] private FormState<RegisterFormModel> RegisterState { get; } = new();
public FormState<RegisterFormModel> RegisterState { get; set; } = new(); private FormState<LoginFormModel> LoginState { get; } = new();
[Parameter]
public FormState<LoginFormModel> LoginState { get; set; } = new();
[Parameter] [Parameter]
public bool IsMutating { get; set; } public bool IsMutating { get; set; }
@@ -81,18 +78,55 @@
public bool StatusIsError { get; set; } public bool StatusIsError { get; set; }
[Parameter] [Parameter]
public EventCallback RegisterSubmitted { get; set; } public Func<RegisterFormModel, Task<FormSubmissionResult>> RegisterSubmitted { get; set; } = _ => Task.FromResult(new FormSubmissionResult());
[Parameter] [Parameter]
public EventCallback LoginSubmitted { get; set; } public Func<LoginFormModel, Task<FormSubmissionResult>> LoginSubmitted { get; set; } = _ => Task.FromResult(new FormSubmissionResult());
private async Task SubmitRegisterAsync() 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() 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<TModel>(FormState<TModel> state, FormSubmissionResult result)
where TModel : new()
{
state.Errors.Clear();
foreach (var (key, value) in result.Errors)
{
state.Errors[key] = value;
}
state.ErrorMessage = result.ErrorMessage;
} }
} }

View File

@@ -29,7 +29,7 @@
{ {
<p class="form-error">@CampaignState.ErrorMessage</p> <p class="form-error">@CampaignState.ErrorMessage</p>
} }
<form class="form-grid" @onsubmit="CreateCampaignSubmitted" @onsubmit:preventDefault> <form class="form-grid" @onsubmit="SubmitCreateCampaignAsync" @onsubmit:preventDefault>
<label for="campaign-name">Campaign name</label> <label for="campaign-name">Campaign name</label>
<input id="campaign-name" @bind="CampaignState.Model.Name" @bind:event="oninput" /> <input id="campaign-name" @bind="CampaignState.Model.Name" @bind:event="oninput" />
@if (CampaignState.Errors.TryGetValue("name", out var campaignNameError)) @if (CampaignState.Errors.TryGetValue("name", out var campaignNameError))
@@ -98,6 +98,8 @@
</main> </main>
@code { @code {
private FormState<CampaignFormModel> CampaignState { get; } = new();
[Parameter] [Parameter]
public IReadOnlyList<CampaignSummary> Campaigns { get; set; } = []; public IReadOnlyList<CampaignSummary> Campaigns { get; set; } = [];
@@ -110,9 +112,6 @@
[Parameter] [Parameter]
public CampaignDetails? SelectedCampaign { get; set; } public CampaignDetails? SelectedCampaign { get; set; }
[Parameter]
public FormState<CampaignFormModel> CampaignState { get; set; } = new();
[Parameter] [Parameter]
public IReadOnlyList<RulesetDefinition> Rulesets { get; set; } = []; public IReadOnlyList<RulesetDefinition> Rulesets { get; set; } = [];
@@ -129,11 +128,42 @@
public EventCallback<ChangeEventArgs> CampaignSelectionChanged { get; set; } public EventCallback<ChangeEventArgs> CampaignSelectionChanged { get; set; }
[Parameter] [Parameter]
public EventCallback CreateCampaignSubmitted { get; set; } public Func<CampaignFormModel, Task<FormSubmissionResult>> CreateCampaignSubmitted { get; set; } = _ => Task.FromResult(new FormSubmissionResult());
[Parameter] [Parameter]
public EventCallback CreateCharacterRequested { get; set; } public EventCallback CreateCharacterRequested { get; set; }
[Parameter] [Parameter]
public EventCallback<CharacterSummary> EditCharacterRequested { get; set; } public EventCallback<CharacterSummary> 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;
}
}
} }

View File

@@ -41,6 +41,9 @@
} }
@code { @code {
private FormState<CharacterFormModel> FormState { get; } = new();
private int AppliedFormVersion { get; set; } = -1;
[Parameter] [Parameter]
public bool Visible { get; set; } public bool Visible { get; set; }
@@ -57,7 +60,10 @@
public string CampaignInputId { get; set; } = "character-campaign"; public string CampaignInputId { get; set; } = "character-campaign";
[Parameter] [Parameter]
public FormState<CharacterFormModel> FormState { get; set; } = new(); public CharacterFormModel InitialModel { get; set; } = new();
[Parameter]
public int FormVersion { get; set; }
[Parameter] [Parameter]
public IReadOnlyList<CampaignSummary> Campaigns { get; set; } = []; public IReadOnlyList<CampaignSummary> Campaigns { get; set; } = [];
@@ -66,13 +72,39 @@
public bool IsMutating { get; set; } public bool IsMutating { get; set; }
[Parameter] [Parameter]
public EventCallback SubmitRequested { get; set; } public Func<CharacterFormModel, Task<FormSubmissionResult>> SubmitRequested { get; set; } = _ => Task.FromResult(new FormSubmissionResult());
[Parameter] [Parameter]
public EventCallback CancelRequested { get; set; } 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() 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;
} }
} }

View File

@@ -45,6 +45,9 @@
} }
@code { @code {
private FormState<SkillFormModel> FormState { get; } = new();
private int AppliedFormVersion { get; set; } = -1;
[Parameter] [Parameter]
public bool Visible { get; set; } public bool Visible { get; set; }
@@ -70,19 +73,52 @@
public string AllowFumbleInputId { get; set; } = "skill-fumble"; public string AllowFumbleInputId { get; set; } = "skill-fumble";
[Parameter] [Parameter]
public FormState<SkillFormModel> FormState { get; set; } = new(); public SkillFormModel InitialModel { get; set; } = new();
[Parameter]
public int FormVersion { get; set; }
[Parameter] [Parameter]
public bool IsMutating { get; set; } public bool IsMutating { get; set; }
[Parameter] [Parameter]
public EventCallback SubmitRequested { get; set; } public Func<SkillFormModel, Task<FormSubmissionResult>> SubmitRequested { get; set; } = _ => Task.FromResult(new FormSubmissionResult());
[Parameter] [Parameter]
public EventCallback CancelRequested { get; set; } 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() 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;
} }
} }

View File

@@ -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`. - 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. - 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<TModel>` containers (`Home.Models.cs`) rather than parallel form/error/message property sets. - Form UX state uses reusable `FormState<TModel>` 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. - 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. - 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. - SSE-driven campaign refresh with reconnect backoff and explicit offline/manual-refresh fallback.