Move skill management ownership into CharacterPanel

This commit is contained in:
2026-02-26 10:19:20 +01:00
parent 54286f80d5
commit 9c31e81977
8 changed files with 109 additions and 95 deletions

View File

@@ -9,6 +9,7 @@ Tracking against `UX.md` tasks and decisions.
- Legacy TypeScript frontend/runtime artifacts: removed - 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. - 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. - Concern controls now own their local form state and mutation workflows; `Home` handles shared cross-control state refresh.
- Skill create/edit flow is now owned by `CharacterPanel` (where characters and their skills are presented together).
## UX Checklist ## UX Checklist

View File

@@ -31,6 +31,7 @@ Frontend:
- `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 and execute their concern-specific API mutations directly - Form ownership model: controls own transient form/error state and execute their concern-specific API mutations directly
- Skill create/edit workflow ownership: `CharacterPanel` (characters own skills in UI and behavior)
- `RpgRoller/Components/RpgRollerApiClient.cs`: shared browser API client used by `Home` and leaf controls - `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/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

@@ -153,16 +153,10 @@ public partial class Home
LastRoll = null; LastRoll = null;
ShowCreateCharacterModal = false; ShowCreateCharacterModal = false;
ShowEditCharacterModal = false; ShowEditCharacterModal = false;
ShowCreateSkillModal = false;
ShowEditSkillModal = false;
CreateCharacterInitialModel = new(); CreateCharacterInitialModel = new();
EditCharacterInitialModel = new(); EditCharacterInitialModel = new();
CreateSkillInitialModel = new();
EditSkillInitialModel = new();
CreateCharacterFormVersion = 0; CreateCharacterFormVersion = 0;
EditCharacterFormVersion = 0; EditCharacterFormVersion = 0;
CreateSkillFormVersion = 0;
EditSkillFormVersion = 0;
} }
private void SetStatus(string message, bool isError) private void SetStatus(string message, bool isError)

View File

@@ -4,49 +4,8 @@ namespace RpgRoller.Components.Pages;
public partial class Home public partial class Home
{ {
private void OpenCreateSkillModal()
{
CreateSkillInitialModel = new SkillFormModel
{
Name = string.Empty,
DiceRollDefinition = string.Empty,
WildDice = IsSelectedCampaignD6 ? 1 : 0,
AllowFumble = IsSelectedCampaignD6
};
CreateSkillFormVersion++;
ShowCreateSkillModal = true;
}
private void OpenEditSkillModal()
{
if (SelectedSkill is null)
{
return;
}
EditingSkillId = SelectedSkill.Id;
EditSkillInitialModel = new SkillFormModel
{
Name = SelectedSkill.Name,
DiceRollDefinition = SelectedSkill.DiceRollDefinition,
WildDice = SelectedSkill.WildDice,
AllowFumble = SelectedSkill.AllowFumble
};
EditSkillFormVersion++;
ShowEditSkillModal = true;
}
private void CloseSkillModals()
{
ShowCreateSkillModal = false;
ShowEditSkillModal = false;
EditingSkillId = null;
}
private async Task OnSkillCreatedAsync(Guid _) private async Task OnSkillCreatedAsync(Guid _)
{ {
CloseSkillModals();
await RefreshCampaignScopeAsync(); await RefreshCampaignScopeAsync();
SetStatus("Skill created.", false); SetStatus("Skill created.", false);
} }
@@ -54,7 +13,6 @@ public partial class Home
private async Task OnSkillUpdatedAsync(Guid skillId) private async Task OnSkillUpdatedAsync(Guid skillId)
{ {
SelectedSkillId = skillId; SelectedSkillId = skillId;
CloseSkillModals();
await RefreshCampaignScopeAsync(); await RefreshCampaignScopeAsync();
SetStatus("Skill updated.", false); SetStatus("Skill updated.", false);
} }

View File

@@ -41,18 +41,11 @@ public partial class Home
private bool ShowCreateCharacterModal { get; set; } private bool ShowCreateCharacterModal { get; set; }
private bool ShowEditCharacterModal { get; set; } private bool ShowEditCharacterModal { get; set; }
private bool ShowCreateSkillModal { get; set; }
private bool ShowEditSkillModal { get; set; }
private Guid? EditingCharacterId { get; set; } private Guid? EditingCharacterId { get; set; }
private Guid? EditingSkillId { get; set; }
private CharacterFormModel CreateCharacterInitialModel { get; set; } = new(); private CharacterFormModel CreateCharacterInitialModel { get; set; } = new();
private CharacterFormModel EditCharacterInitialModel { 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 CreateCharacterFormVersion { get; set; }
private int EditCharacterFormVersion { 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

@@ -74,6 +74,7 @@
SelectedCharacterSkills="SelectedCharacterSkills" SelectedCharacterSkills="SelectedCharacterSkills"
SelectedSkillId="SelectedSkillId" SelectedSkillId="SelectedSkillId"
SelectedSkill="SelectedSkill" SelectedSkill="SelectedSkill"
IsD6="IsSelectedCampaignD6"
RollVisibility="RollVisibility" RollVisibility="RollVisibility"
RollVisibilityChanged="OnRollVisibilityChanged" RollVisibilityChanged="OnRollVisibilityChanged"
LastRoll="LastRoll" LastRoll="LastRoll"
@@ -85,8 +86,8 @@
CharacterSelected="SelectCharacterAsync" CharacterSelected="SelectCharacterAsync"
SkillSelected="SelectSkill" SkillSelected="SelectSkill"
EditCharacterRequested="OpenEditCharacterModal" EditCharacterRequested="OpenEditCharacterModal"
CreateSkillRequested="OpenCreateSkillModal" SkillCreated="OnSkillCreatedAsync"
EditSkillRequested="OpenEditSkillModal" SkillUpdated="OnSkillUpdatedAsync"
RollRequested="RollSelectedSkillAsync" /> RollRequested="RollSelectedSkillAsync" />
<CampaignLogPanel <CampaignLogPanel
@@ -153,37 +154,3 @@
IsMutating="IsMutating" IsMutating="IsMutating"
CharacterSaved="OnCharacterUpdatedAsync" CharacterSaved="OnCharacterUpdatedAsync"
CancelRequested="CloseCharacterModals" /> CancelRequested="CloseCharacterModals" />
<SkillFormModal
Visible="ShowCreateSkillModal"
IsD6="IsSelectedCampaignD6"
Title="Create Skill"
SubmitLabel="Create Skill"
NameInputId="skill-create-name"
ExpressionInputId="skill-create-expression"
WildDiceInputId="skill-create-wild-dice"
AllowFumbleInputId="skill-create-allow-fumble"
InitialModel="CreateSkillInitialModel"
FormVersion="CreateSkillFormVersion"
SelectedCharacterId="SelectedCharacterId"
EditingSkillId="null"
IsMutating="IsMutating"
SkillSaved="OnSkillCreatedAsync"
CancelRequested="CloseSkillModals" />
<SkillFormModal
Visible="ShowEditSkillModal"
IsD6="IsSelectedCampaignD6"
Title="Edit Skill"
SubmitLabel="Save Skill"
NameInputId="skill-edit-name"
ExpressionInputId="skill-edit-expression"
WildDiceInputId="skill-edit-wild-dice"
AllowFumbleInputId="skill-edit-allow-fumble"
InitialModel="EditSkillInitialModel"
FormVersion="EditSkillFormVersion"
SelectedCharacterId="SelectedCharacterId"
EditingSkillId="EditingSkillId"
IsMutating="IsMutating"
SkillSaved="OnSkillUpdatedAsync"
CancelRequested="CloseSkillModals" />

View File

@@ -1,4 +1,5 @@
@using System.Diagnostics.CodeAnalysis @using System.Diagnostics.CodeAnalysis
@using RpgRoller.Components.Pages
@using RpgRoller.Contracts @using RpgRoller.Contracts
@attribute [ExcludeFromCodeCoverage] @attribute [ExcludeFromCodeCoverage]
@@ -43,8 +44,8 @@
<div class="section-head"> <div class="section-head">
<h3>Skills</h3> <h3>Skills</h3>
<div class="inline-actions"> <div class="inline-actions">
<button type="button" disabled="@(IsMutating || !CanEditCharacter(SelectedCharacter))" @onclick="CreateSkillRequested">Create Skill</button> <button type="button" disabled="@(IsMutating || !CanEditCharacter(SelectedCharacter))" @onclick="OpenCreateSkillModal">Create Skill</button>
<button type="button" disabled="@(IsMutating || SelectedSkill is null || !CanEditSkill(SelectedSkill))" @onclick="EditSkillRequested">Edit Skill</button> <button type="button" disabled="@(IsMutating || SelectedSkill is null || !CanEditSkill(SelectedSkill))" @onclick="OpenEditSkillModal">Edit Skill</button>
</div> </div>
</div> </div>
@if (SelectedCharacterSkills.Count == 0) @if (SelectedCharacterSkills.Count == 0)
@@ -90,7 +91,49 @@
</article> </article>
</section> </section>
<SkillFormModal
Visible="ShowCreateSkillModal"
IsD6="IsD6"
Title="Create Skill"
SubmitLabel="Create Skill"
NameInputId="skill-create-name"
ExpressionInputId="skill-create-expression"
WildDiceInputId="skill-create-wild-dice"
AllowFumbleInputId="skill-create-allow-fumble"
InitialModel="CreateSkillInitialModel"
FormVersion="CreateSkillFormVersion"
SelectedCharacterId="SelectedCharacterId"
EditingSkillId="null"
IsMutating="IsMutating"
SkillSaved="OnSkillCreatedAsync"
CancelRequested="CloseSkillModals" />
<SkillFormModal
Visible="ShowEditSkillModal"
IsD6="IsD6"
Title="Edit Skill"
SubmitLabel="Save Skill"
NameInputId="skill-edit-name"
ExpressionInputId="skill-edit-expression"
WildDiceInputId="skill-edit-wild-dice"
AllowFumbleInputId="skill-edit-allow-fumble"
InitialModel="EditSkillInitialModel"
FormVersion="EditSkillFormVersion"
SelectedCharacterId="SelectedCharacterId"
EditingSkillId="EditingSkillId"
IsMutating="IsMutating"
SkillSaved="OnSkillUpdatedAsync"
CancelRequested="CloseSkillModals" />
@code { @code {
private bool ShowCreateSkillModal { get; set; }
private bool ShowEditSkillModal { get; set; }
private Guid? EditingSkillId { get; set; }
private SkillFormModel CreateSkillInitialModel { get; set; } = new();
private SkillFormModel EditSkillInitialModel { get; set; } = new();
private int CreateSkillFormVersion { get; set; }
private int EditSkillFormVersion { get; set; }
[Parameter] [Parameter]
public bool IsCampaignDataLoading { get; set; } public bool IsCampaignDataLoading { get; set; }
@@ -115,6 +158,9 @@
[Parameter] [Parameter]
public SkillSummary? SelectedSkill { get; set; } public SkillSummary? SelectedSkill { get; set; }
[Parameter]
public bool IsD6 { get; set; }
[Parameter] [Parameter]
public string RollVisibility { get; set; } = "public"; public string RollVisibility { get; set; } = "public";
@@ -149,14 +195,67 @@
public EventCallback<CharacterSummary> EditCharacterRequested { get; set; } public EventCallback<CharacterSummary> EditCharacterRequested { get; set; }
[Parameter] [Parameter]
public EventCallback CreateSkillRequested { get; set; } public EventCallback<Guid> SkillCreated { get; set; }
[Parameter] [Parameter]
public EventCallback EditSkillRequested { get; set; } public EventCallback<Guid> SkillUpdated { get; set; }
[Parameter] [Parameter]
public EventCallback RollRequested { get; set; } public EventCallback RollRequested { get; set; }
private void OpenCreateSkillModal()
{
CreateSkillInitialModel = new SkillFormModel
{
Name = string.Empty,
DiceRollDefinition = string.Empty,
WildDice = IsD6 ? 1 : 0,
AllowFumble = IsD6
};
CreateSkillFormVersion++;
ShowCreateSkillModal = true;
}
private void OpenEditSkillModal()
{
if (SelectedSkill is null)
{
return;
}
EditingSkillId = SelectedSkill.Id;
EditSkillInitialModel = new SkillFormModel
{
Name = SelectedSkill.Name,
DiceRollDefinition = SelectedSkill.DiceRollDefinition,
WildDice = SelectedSkill.WildDice,
AllowFumble = SelectedSkill.AllowFumble
};
EditSkillFormVersion++;
ShowEditSkillModal = true;
}
private void CloseSkillModals()
{
ShowCreateSkillModal = false;
ShowEditSkillModal = false;
EditingSkillId = null;
}
private async Task OnSkillCreatedAsync(Guid skillId)
{
CloseSkillModals();
await SkillCreated.InvokeAsync(skillId);
}
private async Task OnSkillUpdatedAsync(Guid skillId)
{
CloseSkillModals();
await SkillUpdated.InvokeAsync(skillId);
}
private async Task OnRollVisibilityChangedAsync(ChangeEventArgs args) private async Task OnRollVisibilityChangedAsync(ChangeEventArgs args)
{ {
var selectedVisibility = args.Value?.ToString() ?? "public"; var selectedVisibility = args.Value?.ToString() ?? "public";

View File

@@ -95,6 +95,7 @@ This pattern is a strong baseline for low to medium scale and should be the defa
- 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 in leaf controls (`HomeControls/*`) rather than parallel form/error/message property sets in `Home`. - Form UX state uses reusable `FormState<TModel>` containers in leaf controls (`HomeControls/*`) rather than parallel form/error/message property sets in `Home`.
- Concern controls execute their own auth/campaign/character/skill mutation workflows and notify `Home` only for shared-state refresh/orchestration. - Concern controls execute their own auth/campaign/character/skill mutation workflows and notify `Home` only for shared-state refresh/orchestration.
- Skill management workflows are owned by `CharacterPanel` to keep character-skill behavior cohesive.
- Shared browser API interop is centralized in `RpgRollerApiClient` and reused by `Home` plus concern controls. - 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. - 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.