Move concern workflows from Home into concern controls

This commit is contained in:
2026-02-26 10:06:42 +01:00
parent 4d728f91cf
commit 54286f80d5
19 changed files with 359 additions and 454 deletions

2
FAQ.md
View File

@@ -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.

View File

@@ -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

View File

@@ -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<TModel>` + 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

View File

@@ -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<T> RequestAsync<T>(string method, string path, object? payload = null)
[Inject]
private RpgRollerApiClient ApiClient { get; set; } = default!;
private Task<T> RequestAsync<T>(string method, string path, object? payload = null)
{
var response = await JS.InvokeAsync<JsApiResponse>("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<T>(JsonOptions)!;
return ApiClient.RequestAsync<T>(method, path, payload);
}
private async Task RequestWithoutPayloadAsync(string method, string path)
private Task RequestWithoutPayloadAsync(string method, string path)
{
var response = await JS.InvokeAsync<JsApiResponse>("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);
}
}

View File

@@ -1,97 +1,19 @@
using RpgRoller.Contracts;
using RpgRoller.Components;
namespace RpgRoller.Components.Pages;
public partial class Home
{
private async Task<FormSubmissionResult> RegisterAsync(RegisterFormModel model)
private async Task OnLoggedInAsync()
{
var validationErrors = new Dictionary<string, string>();
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<UserSummary>(
"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<string, string>
{
["username"] = "Username is already taken. Choose another one."
}
};
}
return new FormSubmissionResult { ErrorMessage = ex.Message };
}
finally
{
IsMutating = false;
}
}
private async Task<FormSubmissionResult> LoginAsync(LoginFormModel model)
{
var validationErrors = new Dictionary<string, string>();
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<UserSummary>(
"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);
}
}

View File

@@ -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<FormSubmissionResult> CreateCampaignAsync(CampaignFormModel model)
private async Task OnCampaignCreatedAsync(Guid campaignId)
{
var validationErrors = new Dictionary<string, string>();
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<CampaignSummary>(
"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);
}
}

View File

@@ -35,100 +35,22 @@ public partial class Home
EditingCharacterId = null;
}
private async Task<FormSubmissionResult> CreateCharacterAsync(CharacterFormModel model)
private async Task OnCharacterCreatedAsync(Guid campaignId)
{
var validationErrors = new Dictionary<string, string>();
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<CharacterSummary>(
"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<FormSubmissionResult> UpdateCharacterAsync(CharacterFormModel model)
private async Task OnCharacterUpdatedAsync(Guid campaignId)
{
if (!EditingCharacterId.HasValue)
{
return new FormSubmissionResult { ErrorMessage = "No character selected." };
}
var validationErrors = new Dictionary<string, string>();
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<CharacterSummary>(
"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)

View File

@@ -14,13 +14,6 @@ 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 string Username { get; set; } = string.Empty;

View File

@@ -44,101 +44,19 @@ public partial class Home
EditingSkillId = null;
}
private async Task<FormSubmissionResult> CreateSkillAsync(SkillFormModel model)
private async Task OnSkillCreatedAsync(Guid _)
{
if (SelectedCharacter is null)
{
return new FormSubmissionResult { ErrorMessage = "Select a character first." };
}
var validationErrors = new Dictionary<string, string>();
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<SkillSummary>(
"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<FormSubmissionResult> UpdateSkillAsync(SkillFormModel model)
private async Task OnSkillUpdatedAsync(Guid skillId)
{
if (!EditingSkillId.HasValue)
{
return new FormSubmissionResult { ErrorMessage = "No skill selected." };
}
var validationErrors = new Dictionary<string, string>();
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<SkillSummary>(
"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()

View File

@@ -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; }

View File

@@ -2,22 +2,4 @@ namespace RpgRoller.Components.Pages;
public partial class Home
{
private static void AddRequiredError(Dictionary<string, string> errors, string field, string? value, string message)
{
if (string.IsNullOrWhiteSpace(value))
{
errors[field] = message;
}
}
private static bool TryParseGuid(string? rawValue, Dictionary<string, string> errors, string field, string message, out Guid parsed)
{
if (Guid.TryParse(rawValue, out parsed))
{
return true;
}
errors[field] = message;
return false;
}
}

View File

@@ -27,11 +27,9 @@
case HomeViewMode.Anonymous:
<AuthSection
IsMutating="IsMutating"
StatusMessage="StatusMessage"
StatusIsError="StatusIsError"
RegisterSubmitted="RegisterAsync"
LoginSubmitted="LoginAsync" />
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" />
<CharacterFormModal
@@ -149,9 +148,10 @@
CampaignInputId="character-edit-campaign"
InitialModel="EditCharacterInitialModel"
FormVersion="EditCharacterFormVersion"
EditingCharacterId="EditingCharacterId"
Campaigns="Campaigns"
IsMutating="IsMutating"
SubmitRequested="UpdateCharacterAsync"
CharacterSaved="OnCharacterUpdatedAsync"
CancelRequested="CloseCharacterModals" />
<SkillFormModal
@@ -165,8 +165,10 @@
AllowFumbleInputId="skill-create-allow-fumble"
InitialModel="CreateSkillInitialModel"
FormVersion="CreateSkillFormVersion"
SelectedCharacterId="SelectedCharacterId"
EditingSkillId="null"
IsMutating="IsMutating"
SubmitRequested="CreateSkillAsync"
SkillSaved="OnSkillCreatedAsync"
CancelRequested="CloseSkillModals" />
<SkillFormModal
@@ -180,6 +182,8 @@
AllowFumbleInputId="skill-edit-allow-fumble"
InitialModel="EditSkillInitialModel"
FormVersion="EditSkillFormVersion"
SelectedCharacterId="SelectedCharacterId"
EditingSkillId="EditingSkillId"
IsMutating="IsMutating"
SubmitRequested="UpdateSkillAsync"
SkillSaved="OnSkillUpdatedAsync"
CancelRequested="CloseSkillModals" />

View File

@@ -1,6 +1,9 @@
@using System.Diagnostics.CodeAnalysis
@using RpgRoller.Components
@using RpgRoller.Components.Pages
@using RpgRoller.Contracts
@attribute [ExcludeFromCodeCoverage]
@inject RpgRollerApiClient ApiClient
<main class="auth-shell">
<h1>RpgRoller</h1>
@@ -35,7 +38,7 @@
{
<p class="field-error">@registerPasswordError</p>
}
<button type="submit" disabled="@IsMutating">@(IsMutating ? "Registering..." : "Register")</button>
<button type="submit" disabled="@IsSubmitting">@(IsSubmitting ? "Registering..." : "Register")</button>
</form>
</section>
@@ -58,7 +61,7 @@
{
<p class="field-error">@loginPasswordError</p>
}
<button type="submit" disabled="@IsMutating">@(IsMutating ? "Logging in..." : "Login")</button>
<button type="submit" disabled="@IsSubmitting">@(IsSubmitting ? "Logging in..." : "Login")</button>
</form>
</section>
</div>
@@ -67,9 +70,7 @@
@code {
private FormState<RegisterFormModel> RegisterState { get; } = new();
private FormState<LoginFormModel> 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<RegisterFormModel, Task<FormSubmissionResult>> RegisterSubmitted { get; set; } = _ => Task.FromResult(new FormSubmissionResult());
[Parameter]
public Func<LoginFormModel, Task<FormSubmissionResult>> 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<UserSummary>(
"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<TModel>(FormState<TModel> 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<UserSummary>(
"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;
}
}
}

View File

@@ -1,7 +1,9 @@
@using System.Diagnostics.CodeAnalysis
@using RpgRoller.Components
@using RpgRoller.Components.Pages
@using RpgRoller.Contracts
@attribute [ExcludeFromCodeCoverage]
@inject RpgRollerApiClient ApiClient
<main class="management-screen">
<section class="card">
@@ -48,7 +50,7 @@
{
<p class="field-error">@campaignRulesetError</p>
}
<button type="submit" disabled="@IsMutating">@(IsMutating ? "Creating..." : "Create Campaign")</button>
<button type="submit" disabled="@(IsMutating || IsCreatingCampaign)">@(IsCreatingCampaign ? "Creating..." : "Create Campaign")</button>
</form>
</section>
@@ -70,7 +72,7 @@
<section class="card">
<div class="section-head">
<h2>Character Management</h2>
<button type="button" disabled="@(IsMutating || SelectedCampaign is null)" @onclick="CreateCharacterRequested">Create Character</button>
<button type="button" disabled="@(IsMutating || IsCreatingCampaign || SelectedCampaign is null)" @onclick="CreateCharacterRequested">Create Character</button>
</div>
@if (SelectedCampaign is null)
{
@@ -88,7 +90,7 @@
<li>
<div><strong>@character.Name</strong><p class="muted">Owner: @OwnerLabel(character.OwnerUserId)</p></div>
<div class="inline-actions">
<button type="button" disabled="@(IsMutating || !CanEditCharacter(character))" @onclick="() => EditCharacterRequested.InvokeAsync(character)">Edit</button>
<button type="button" disabled="@(IsMutating || IsCreatingCampaign || !CanEditCharacter(character))" @onclick="() => EditCharacterRequested.InvokeAsync(character)">Edit</button>
</div>
</li>
}
@@ -99,6 +101,7 @@
@code {
private FormState<CampaignFormModel> CampaignState { get; } = new();
private bool IsCreatingCampaign { get; set; }
[Parameter]
public IReadOnlyList<CampaignSummary> Campaigns { get; set; } = [];
@@ -128,7 +131,7 @@
public EventCallback<ChangeEventArgs> CampaignSelectionChanged { get; set; }
[Parameter]
public Func<CampaignFormModel, Task<FormSubmissionResult>> CreateCampaignSubmitted { get; set; } = _ => Task.FromResult(new FormSubmissionResult());
public EventCallback<Guid> 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<CampaignSummary>(
"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;
}
}
}

View File

@@ -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 @@
<p class="field-error">@campaignError</p>
}
<div class="inline-actions">
<button type="submit" disabled="@IsMutating">@SubmitLabel</button>
<button type="submit" disabled="@(IsMutating || IsSubmitting)">@SubmitLabel</button>
<button type="button" class="ghost" @onclick="CancelRequested">Cancel</button>
</div>
</form>
@@ -43,6 +45,7 @@
@code {
private FormState<CharacterFormModel> 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<CampaignSummary> Campaigns { get; set; } = [];
@@ -72,7 +78,7 @@
public bool IsMutating { get; set; }
[Parameter]
public Func<CharacterFormModel, Task<FormSubmissionResult>> SubmitRequested { get; set; } = _ => Task.FromResult(new FormSubmissionResult());
public EventCallback<Guid> 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<CharacterSummary>(
"PUT",
$"/api/characters/{EditingCharacterId.Value}",
new UpdateCharacterRequest(FormState.Model.Name.Trim(), campaignId));
}
else
{
character = await ApiClient.RequestAsync<CharacterSummary>(
"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;
}
}
}

View File

@@ -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 @@
<input id="@AllowFumbleInputId" type="checkbox" @bind="FormState.Model.AllowFumble" />
}
<div class="inline-actions">
<button type="submit" disabled="@IsMutating">@SubmitLabel</button>
<button type="submit" disabled="@(IsMutating || IsSubmitting)">@SubmitLabel</button>
<button type="button" class="ghost" @onclick="CancelRequested">Cancel</button>
</div>
</form>
@@ -47,6 +50,7 @@
@code {
private FormState<SkillFormModel> 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<SkillFormModel, Task<FormSubmissionResult>> SubmitRequested { get; set; } = _ => Task.FromResult(new FormSubmissionResult());
public EventCallback<Guid> 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<SkillSummary>(
"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<SkillSummary>(
"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;
}
}
}

View File

@@ -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<T> RequestAsync<T>(string method, string path, object? payload = null)
{
var response = await m_Js.InvokeAsync<JsApiResponse>("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<T>(JsonOptions)!;
}
public async Task RequestWithoutPayloadAsync(string method, string path)
{
var response = await m_Js.InvokeAsync<JsApiResponse>("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; }
}

View File

@@ -5,6 +5,7 @@ var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRpgRollerCore(builder.Configuration, builder.Environment);
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
builder.Services.AddScoped<RpgRoller.Components.RpgRollerApiClient>();
var app = builder.Build();
app.InitializeRpgRollerState();

View File

@@ -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<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.
- 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.