Stage workspace controls after bootstrap

This commit is contained in:
2026-05-04 19:03:47 +02:00
parent da813583bd
commit e0b7d27ba7
8 changed files with 542 additions and 575 deletions

View File

@@ -1,218 +1,74 @@
using Microsoft.AspNetCore.Http; using System.Text.Json;
using Microsoft.JSInterop;
using RpgRoller.Components; using RpgRoller.Components;
namespace RpgRoller.Tests; namespace RpgRoller.Tests;
public sealed class WorkspaceQueryServiceTests public sealed class WorkspaceQueryServiceTests
{ {
private sealed class StubGameService : IGameService private sealed class StubJsRuntime(Func<string, object?[]?, Type, object?> handler) : IJSRuntime
{ {
public IReadOnlyList<RulesetDefinition> GetRulesets() public ValueTask<TValue> InvokeAsync<TValue>(string identifier, object?[]? args)
{ {
throw new NotSupportedException(); return ValueTask.FromResult((TValue)handler(identifier, args, typeof(TValue))!);
} }
public ServiceResult<UserSummary> Register(string username, string password, string displayName) public ValueTask<TValue> InvokeAsync<TValue>(string identifier, CancellationToken cancellationToken,
object?[]? args)
{ {
throw new NotSupportedException(); return InvokeAsync<TValue>(identifier, args);
} }
public ServiceResult<(UserSummary User, string SessionToken)> Login(string username, string password)
{
throw new NotSupportedException();
}
public void Logout(string sessionToken)
{
throw new NotSupportedException();
}
public UserSummary? GetUserBySession(string sessionToken)
{
throw new NotSupportedException();
}
public ServiceResult<MeResponse> GetMe(string sessionToken)
{
return GetMeHandler(sessionToken);
}
public ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, string name, string rulesetId)
{
throw new NotSupportedException();
}
public ServiceResult<IReadOnlyList<CampaignSummary>> GetCampaigns(string sessionToken)
{
return GetCampaignsHandler(sessionToken);
}
public ServiceResult<IReadOnlyList<CampaignOption>> GetCharacterCampaignOptions(string sessionToken)
{
throw new NotSupportedException();
}
public ServiceResult<CampaignRoster> GetCampaign(string sessionToken, Guid campaignId)
{
throw new NotSupportedException();
}
public ServiceResult<bool> DeleteCampaign(string sessionToken, Guid campaignId)
{
throw new NotSupportedException();
}
public ServiceResult<IReadOnlyList<string>> GetUsernames(string sessionToken)
{
throw new NotSupportedException();
}
public ServiceResult<IReadOnlyList<AdminUserSummary>> GetUsers(string sessionToken)
{
throw new NotSupportedException();
}
public ServiceResult<AdminUserSummary> UpdateUserRoles(string sessionToken, Guid userId, IReadOnlyList<string> roles)
{
throw new NotSupportedException();
}
public ServiceResult<bool> DeleteUser(string sessionToken, Guid userId)
{
throw new NotSupportedException();
}
public ServiceResult<CharacterSummary> CreateCharacter(string sessionToken, string name, Guid campaignId)
{
throw new NotSupportedException();
}
public ServiceResult<CharacterSummary> UpdateCharacter(string sessionToken, Guid characterId, string name, Guid? campaignId, string? ownerUsername = null)
{
throw new NotSupportedException();
}
public ServiceResult<bool> DeleteCharacter(string sessionToken, Guid characterId)
{
throw new NotSupportedException();
}
public ServiceResult<bool> ActivateCharacter(string sessionToken, Guid characterId)
{
throw new NotSupportedException();
}
public ServiceResult<IReadOnlyList<CharacterSummary>> GetOwnCharacters(string sessionToken)
{
throw new NotSupportedException();
}
public ServiceResult<SkillGroupSummary> CreateSkillGroup(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange = null)
{
throw new NotSupportedException();
}
public ServiceResult<SkillGroupSummary> UpdateSkillGroup(string sessionToken, Guid skillGroupId, string name, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange = null)
{
throw new NotSupportedException();
}
public ServiceResult<bool> DeleteSkillGroup(string sessionToken, Guid skillGroupId)
{
throw new NotSupportedException();
}
public ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null, bool rolemasterAutoRetry = false)
{
throw new NotSupportedException();
}
public ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null, bool rolemasterAutoRetry = false)
{
throw new NotSupportedException();
}
public ServiceResult<bool> DeleteSkill(string sessionToken, Guid skillId)
{
throw new NotSupportedException();
}
public ServiceResult<CharacterSheet> GetCharacterSheet(string sessionToken, Guid characterId)
{
throw new NotSupportedException();
}
public ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility, int situationalModifier = 0)
{
throw new NotSupportedException();
}
public ServiceResult<RollResult> RollCustom(string sessionToken, Guid characterId, string expression, string visibility)
{
throw new NotSupportedException();
}
public ServiceResult<IReadOnlyList<CampaignLogEntry>> GetCampaignLog(string sessionToken, Guid campaignId)
{
throw new NotSupportedException();
}
public ServiceResult<CampaignLogPage> GetCampaignLogPage(string sessionToken, Guid campaignId, Guid? afterRollId = null, int? limit = null)
{
throw new NotSupportedException();
}
public ServiceResult<CampaignRollDetail> GetRollDetail(string sessionToken, Guid rollId)
{
throw new NotSupportedException();
}
public ServiceResult<CampaignStateSnapshot> GetCampaignStateSnapshot(string sessionToken, Guid campaignId)
{
throw new NotSupportedException();
}
public Func<string, ServiceResult<MeResponse>> GetMeHandler { get; init; } = _ => ServiceResult<MeResponse>.Failure("unexpected_call", "Unexpected GetMe call.");
public Func<string, ServiceResult<IReadOnlyList<CampaignSummary>>> GetCampaignsHandler { get; init; } = _ => ServiceResult<IReadOnlyList<CampaignSummary>>.Failure("unexpected_call", "Unexpected GetCampaigns call.");
} }
[Fact] [Fact]
public void SessionTokenAccessor_ReadsSessionCookieFromHttpContext() public async Task GetCampaignsAsync_UsesCampaignsApiEndpoint()
{ {
var httpContext = new DefaultHttpContext(); var queryService = new WorkspaceQueryService(new RpgRollerApiClient(
httpContext.Request.Headers.Cookie = "rpgroller_session=session-token"; new StubJsRuntime((identifier, args, returnType) =>
{
Assert.Equal("rpgRollerApi.request", identifier);
Assert.Equal("GET", args![0]);
Assert.Equal("/api/campaigns", args[1]);
Assert.Null(args[2]);
var accessor = new HttpContextAccessor { HttpContext = httpContext }; return CreateJsApiResponse(args: new
var sessionTokenAccessor = new WorkspaceSessionTokenAccessor(accessor); {
ok = true,
Assert.Equal("session-token", sessionTokenAccessor.GetRequiredSessionToken()); status = 200,
data = new[]
{
new CampaignSummary(Guid.NewGuid(), "Alpha", "d6", new CampaignGmSummary(Guid.NewGuid(), "GM"),
1)
} }
}, returnType);
})));
[Fact]
public async Task GetCampaignsAsync_UsesCapturedSessionToken()
{
var service = new StubGameService
{
GetCampaignsHandler = sessionToken =>
{
Assert.Equal("server-session", sessionToken);
return ServiceResult<IReadOnlyList<CampaignSummary>>.Success([new(Guid.NewGuid(), "Alpha", "d6", new(Guid.NewGuid(), "GM"), 1)]);
}
};
var queryService = new WorkspaceQueryService(service, CreateSessionTokenAccessor("server-session"));
var campaigns = await queryService.GetCampaignsAsync(); var campaigns = await queryService.GetCampaignsAsync();
Assert.Single(campaigns); var campaign = Assert.Single(campaigns);
Assert.Equal("Alpha", campaign.Name);
} }
[Fact] [Fact]
public async Task GetMeAsync_MapsUnauthorizedServiceResultToApiRequestException() public async Task GetMeAsync_MapsUnauthorizedApiResponseToApiRequestException()
{ {
var service = new StubGameService { GetMeHandler = _ => ServiceResult<MeResponse>.Failure("unauthorized", "You must be logged in.") }; var queryService = new WorkspaceQueryService(new RpgRollerApiClient(
new StubJsRuntime((identifier, args, returnType) =>
{
Assert.Equal("rpgRollerApi.request", identifier);
Assert.Equal("GET", args![0]);
Assert.Equal("/api/me", args[1]);
return CreateJsApiResponse(args: new
{
ok = false,
status = 401,
error = "You must be logged in.",
code = "unauthorized",
data = (object?)null
}, returnType);
})));
var queryService = new WorkspaceQueryService(service, CreateSessionTokenAccessor("expired-session"));
var exception = await Assert.ThrowsAsync<ApiRequestException>(queryService.GetMeAsync); var exception = await Assert.ThrowsAsync<ApiRequestException>(queryService.GetMeAsync);
Assert.Equal(401, exception.StatusCode); Assert.Equal(401, exception.StatusCode);
@@ -220,10 +76,11 @@ public sealed class WorkspaceQueryServiceTests
Assert.Equal("unauthorized", exception.ErrorCode); Assert.Equal("unauthorized", exception.ErrorCode);
} }
private static WorkspaceSessionTokenAccessor CreateSessionTokenAccessor(string sessionToken) private static object CreateJsApiResponse(object args, Type returnType)
{ {
var httpContext = new DefaultHttpContext(); var json = JsonSerializer.Serialize(args);
httpContext.Request.Headers.Cookie = $"rpgroller_session={sessionToken}"; return JsonSerializer.Deserialize(json, returnType, JsonOptions)!;
return new(new HttpContextAccessor { HttpContext = httpContext });
} }
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
} }

View File

@@ -82,6 +82,8 @@
</div> </div>
<section class="custom-roll-panel" aria-label="Custom roll panel"> <section class="custom-roll-panel" aria-label="Custom roll panel">
@if (EnableCustomRollComposer)
{
<form class="custom-roll-composer" @onsubmit="SubmitCustomRollAsync" @onsubmit:preventDefault> <form class="custom-roll-composer" @onsubmit="SubmitCustomRollAsync" @onsubmit:preventDefault>
<div class="custom-roll-composer-head"> <div class="custom-roll-composer-head">
<label for="custom-roll-expression" class="custom-roll-label">Custom roll</label> <label for="custom-roll-expression" class="custom-roll-label">Custom roll</label>
@@ -107,5 +109,16 @@
<p id="@CustomRollErrorElementId" class="field-error" role="alert">@CustomRollErrorMessage</p> <p id="@CustomRollErrorElementId" class="field-error" role="alert">@CustomRollErrorMessage</p>
} }
</form> </form>
}
else
{
<div class="custom-roll-composer">
<div class="custom-roll-composer-head">
<span class="custom-roll-label">Custom roll</span>
<span class="muted">@CustomRollStatusText</span>
</div>
<p class="field-help">Loading roll composer...</p>
</div>
}
</section> </section>
</aside> </aside>

View File

@@ -29,7 +29,8 @@ public partial class CampaignLogPanel
catch (JSDisconnectedException) catch (JSDisconnectedException)
{ {
} }
catch (InvalidOperationException ex) when (ex.Message.Contains("statically rendered", StringComparison.OrdinalIgnoreCase)) catch (InvalidOperationException ex) when (ex.Message.Contains("statically rendered",
StringComparison.OrdinalIgnoreCase))
{ {
} }
} }
@@ -58,7 +59,8 @@ public partial class CampaignLogPanel
IsSubmittingCustomRoll = true; IsSubmittingCustomRoll = true;
try try
{ {
var roll = await ApiClient.RequestAsync<RollResult>("POST", $"/api/characters/{SelectedCharacterId.Value}/custom-rolls", new var roll = await ApiClient.RequestAsync<RollResult>("POST",
$"/api/characters/{SelectedCharacterId.Value}/custom-rolls", new
{ {
expression, expression,
visibility = NormalizedRollVisibility visibility = NormalizedRollVisibility
@@ -71,7 +73,8 @@ public partial class CampaignLogPanel
await JS.InvokeVoidAsync("rpgRollerApi.clearInputValue", CustomRollInputRef); await JS.InvokeVoidAsync("rpgRollerApi.clearInputValue", CustomRollInputRef);
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
} }
catch (ApiRequestException ex) when (string.Equals(ex.ErrorCode, "invalid_expression", StringComparison.Ordinal)) catch (ApiRequestException ex) when
(string.Equals(ex.ErrorCode, "invalid_expression", StringComparison.Ordinal))
{ {
SetCustomRollError(ex.Message); SetCustomRollError(ex.Message);
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
@@ -93,7 +96,8 @@ public partial class CampaignLogPanel
private static IReadOnlyList<EventBadgeView> GetEventBadges(CampaignLogListEntry entry) private static IReadOnlyList<EventBadgeView> GetEventBadges(CampaignLogListEntry entry)
{ {
return (entry.EventBadges ?? []).Select(ToEventBadgeView).Where(badge => badge is not null).Cast<EventBadgeView>().ToArray(); return (entry.EventBadges ?? []).Select(ToEventBadgeView).Where(badge => badge is not null)
.Cast<EventBadgeView>().ToArray();
} }
private static bool HasSummary(CampaignLogListEntry entry) private static bool HasSummary(CampaignLogListEntry entry)
@@ -130,11 +134,9 @@ public partial class CampaignLogPanel
return string.Join(" ", classes); return string.Join(" ", classes);
} }
[Inject] [Inject] private IJSRuntime JS { get; set; } = null!;
private IJSRuntime JS { get; set; } = null!;
[Inject] [Inject] private RpgRollerApiClient ApiClient { get; set; } = null!;
private RpgRollerApiClient ApiClient { get; set; } = null!;
private ElementReference LogPanelRef { get; set; } private ElementReference LogPanelRef { get; set; }
private ElementReference LogFeedRef { get; set; } private ElementReference LogFeedRef { get; set; }
@@ -145,54 +147,44 @@ public partial class CampaignLogPanel
private FormState<CustomRollFormModel> CustomRollState { get; } = new(); private FormState<CustomRollFormModel> CustomRollState { get; } = new();
private bool IsSubmittingCustomRoll { get; set; } private bool IsSubmittingCustomRoll { get; set; }
[Parameter] [Parameter] public bool IsCampaignDataLoading { get; set; }
public bool IsCampaignDataLoading { get; set; }
[Parameter] [Parameter] public IReadOnlyList<CampaignLogListEntry> CampaignLog { get; set; } = [];
public IReadOnlyList<CampaignLogListEntry> CampaignLog { get; set; } = [];
[Parameter] [Parameter] public Guid? ExpandedRollId { get; set; }
public Guid? ExpandedRollId { get; set; }
[Parameter] [Parameter] public Guid? FreshRollId { get; set; }
public Guid? FreshRollId { get; set; }
[Parameter] [Parameter] public EventCallback<Guid> ToggleRollDetailRequested { get; set; }
public EventCallback<Guid> ToggleRollDetailRequested { get; set; }
[Parameter] [Parameter] public Func<Guid, CampaignRollDetail?> ResolveRollDetail { get; set; } = _ => null;
public Func<Guid, CampaignRollDetail?> ResolveRollDetail { get; set; } = _ => null;
[Parameter] [Parameter] public Func<Guid, bool> IsRollDetailLoading { get; set; } = _ => false;
public Func<Guid, bool> IsRollDetailLoading { get; set; } = _ => false;
[Parameter] [Parameter] public Func<Guid, string?> GetRollDetailError { get; set; } = _ => null;
public Func<Guid, string?> GetRollDetailError { get; set; } = _ => null;
[Parameter] [Parameter] public Guid? SelectedCharacterId { get; set; }
public Guid? SelectedCharacterId { get; set; }
[Parameter] [Parameter] public string? SelectedCharacterName { get; set; }
public string? SelectedCharacterName { get; set; }
[Parameter] [Parameter] public string SelectedCampaignRulesetId { get; set; } = string.Empty;
public string SelectedCampaignRulesetId { get; set; } = string.Empty;
[Parameter] [Parameter] public string RollVisibility { get; set; } = "public";
public string RollVisibility { get; set; } = "public";
[Parameter] [Parameter] public bool EnableCustomRollComposer { get; set; }
public bool IsMutating { get; set; }
[Parameter] [Parameter] public bool IsMutating { get; set; }
public EventCallback<RollResult> CustomRollCreated { get; set; }
[Parameter] [Parameter] public EventCallback<RollResult> CustomRollCreated { get; set; }
public EventCallback<string> ErrorOccurred { get; set; }
[Parameter] public EventCallback<string> ErrorOccurred { get; set; }
private bool HasCustomRollError => CustomRollState.Errors.ContainsKey("expression"); private bool HasCustomRollError => CustomRollState.Errors.ContainsKey("expression");
private string? CustomRollErrorMessage => CustomRollState.Errors.GetValueOrDefault("expression"); private string? CustomRollErrorMessage => CustomRollState.Errors.GetValueOrDefault("expression");
private bool IsCustomRollDisabled => IsCampaignDataLoading || IsMutating || IsSubmittingCustomRoll || !SelectedCharacterId.HasValue;
private bool IsCustomRollDisabled =>
IsCampaignDataLoading || IsMutating || IsSubmittingCustomRoll || !SelectedCharacterId.HasValue;
private string CustomRollInputCssClass => HasCustomRollError ? "custom-roll-input error" : "custom-roll-input"; private string CustomRollInputCssClass => HasCustomRollError ? "custom-roll-input error" : "custom-roll-input";
private string? CustomRollInputTitle => HasCustomRollError ? CustomRollErrorMessage : null; private string? CustomRollInputTitle => HasCustomRollError ? CustomRollErrorMessage : null;
private string CustomRollErrorElementId => "custom-roll-expression-error"; private string CustomRollErrorElementId => "custom-roll-expression-error";
@@ -206,17 +198,25 @@ public partial class CampaignLogPanel
_ => "Enter a roll expression" _ => "Enter a roll expression"
}; };
private string CustomRollStatusText => SelectedCharacterId.HasValue && !string.IsNullOrWhiteSpace(SelectedCharacterName) ? $"For {SelectedCharacterName} • {RollVisibilityLabel}" : "Select a character to enable"; private string CustomRollStatusText =>
SelectedCharacterId.HasValue && !string.IsNullOrWhiteSpace(SelectedCharacterName)
? $"For {SelectedCharacterName} • {RollVisibilityLabel}"
: "Select a character to enable";
private string CustomRollHelpText => SelectedCampaignRulesetId.ToLowerInvariant() switch private string CustomRollHelpText => SelectedCampaignRulesetId.ToLowerInvariant() switch
{ {
RulesetFormHelpers.RulesetIds.D6 => "Uses the campaign ruleset. D6 custom rolls use one wild die and allow fumbles.", RulesetFormHelpers.RulesetIds.D6 =>
RulesetFormHelpers.RulesetIds.Rolemaster => $"{RulesetFormHelpers.RolemasterExampleText()}. Logged for the selected character.", "Uses the campaign ruleset. D6 custom rolls use one wild die and allow fumbles.",
RulesetFormHelpers.RulesetIds.Rolemaster =>
$"{RulesetFormHelpers.RolemasterExampleText()}. Logged for the selected character.",
_ => "Uses the selected campaign ruleset and current visibility." _ => "Uses the selected campaign ruleset and current visibility."
}; };
private string RollVisibilityLabel => string.Equals(NormalizedRollVisibility, "private", StringComparison.OrdinalIgnoreCase) ? "Private" : "Public"; private string RollVisibilityLabel =>
private string NormalizedRollVisibility => string.Equals(RollVisibility, "private", StringComparison.OrdinalIgnoreCase) ? "private" : "public"; string.Equals(NormalizedRollVisibility, "private", StringComparison.OrdinalIgnoreCase) ? "Private" : "Public";
private string NormalizedRollVisibility =>
string.Equals(RollVisibility, "private", StringComparison.OrdinalIgnoreCase) ? "private" : "public";
private string CustomRollExpression private string CustomRollExpression
{ {

View File

@@ -49,6 +49,8 @@
</span> </span>
</h3> </h3>
<div class="skill-filter-wrap"> <div class="skill-filter-wrap">
@if (EnableInteractiveControls)
{
<label class="sr-only" for="skill-filter-input">Filter skills</label> <label class="sr-only" for="skill-filter-input">Filter skills</label>
<input id="skill-filter-input" <input id="skill-filter-input"
class="skill-filter-input" class="skill-filter-input"
@@ -56,13 +58,25 @@
placeholder="Filter skills" placeholder="Filter skills"
@bind="SkillFilterText" @bind="SkillFilterText"
@bind:event="oninput"/> @bind:event="oninput"/>
}
else
{
<p class="muted">Loading skill controls...</p>
}
</div> </div>
<div class="chip-toolbar"> <div class="chip-toolbar">
@if (EnableInteractiveControls)
{
<label class="visibility-control" for="roll-visibility">Visibility</label> <label class="visibility-control" for="roll-visibility">Visibility</label>
<select id="roll-visibility" value="@(RollVisibility == "private" ? "private" : "public")" @onchange="OnRollVisibilityChangedAsync"> <select id="roll-visibility" value="@(RollVisibility == "private" ? "private" : "public")" @onchange="OnRollVisibilityChangedAsync">
<option value="public">Public</option> <option value="public">Public</option>
<option value="private">Private</option> <option value="private">Private</option>
</select> </select>
}
else
{
<p class="muted">Visibility: @(RollVisibility == "private" ? "Private" : "Public")</p>
}
</div> </div>
</div> </div>
@{ @{

View File

@@ -9,7 +9,9 @@ public partial class CharacterPanel
{ {
private void OpenCreateSkillModal(Guid? skillGroupId = null) private void OpenCreateSkillModal(Guid? skillGroupId = null)
{ {
var selectedGroup = skillGroupId.HasValue ? SelectedCharacterSkillGroups.FirstOrDefault(group => group.Id == skillGroupId.Value) : null; var selectedGroup = skillGroupId.HasValue
? SelectedCharacterSkillGroups.FirstOrDefault(group => group.Id == skillGroupId.Value)
: null;
CreateSkillInitialModel = new() CreateSkillInitialModel = new()
{ {
@@ -176,7 +178,11 @@ public partial class CharacterPanel
try try
{ {
var selectedCharacterId = SelectedCharacterId!.Value; var selectedCharacterId = SelectedCharacterId!.Value;
var createdGroup = await ApiClient.RequestAsync<SkillGroupSummary>("POST", $"/api/characters/{selectedCharacterId}/skill-groups", new CreateSkillGroupRequest(SkillGroupState.Model.Name.Trim(), SkillGroupState.Model.DiceRollDefinition.Trim(), SkillGroupState.Model.WildDice, SkillGroupState.Model.AllowFumble, SkillGroupState.Model.FumbleRange)); var createdGroup = await ApiClient.RequestAsync<SkillGroupSummary>("POST",
$"/api/characters/{selectedCharacterId}/skill-groups",
new CreateSkillGroupRequest(SkillGroupState.Model.Name.Trim(),
SkillGroupState.Model.DiceRollDefinition.Trim(), SkillGroupState.Model.WildDice,
SkillGroupState.Model.AllowFumble, SkillGroupState.Model.FumbleRange));
CloseSkillGroupModals(); CloseSkillGroupModals();
await SkillGroupCreated.InvokeAsync(createdGroup.Id); await SkillGroupCreated.InvokeAsync(createdGroup.Id);
} }
@@ -230,7 +236,11 @@ public partial class CharacterPanel
try try
{ {
var editingSkillGroupId = EditingSkillGroupId!.Value; var editingSkillGroupId = EditingSkillGroupId!.Value;
var updatedGroup = await ApiClient.RequestAsync<SkillGroupSummary>("PUT", $"/api/skill-groups/{editingSkillGroupId}", new UpdateSkillGroupRequest(SkillGroupState.Model.Name.Trim(), SkillGroupState.Model.DiceRollDefinition.Trim(), SkillGroupState.Model.WildDice, SkillGroupState.Model.AllowFumble, SkillGroupState.Model.FumbleRange)); var updatedGroup = await ApiClient.RequestAsync<SkillGroupSummary>("PUT",
$"/api/skill-groups/{editingSkillGroupId}",
new UpdateSkillGroupRequest(SkillGroupState.Model.Name.Trim(),
SkillGroupState.Model.DiceRollDefinition.Trim(), SkillGroupState.Model.WildDice,
SkillGroupState.Model.AllowFumble, SkillGroupState.Model.FumbleRange));
CloseSkillGroupModals(); CloseSkillGroupModals();
await SkillGroupUpdated.InvokeAsync(updatedGroup.Id); await SkillGroupUpdated.InvokeAsync(updatedGroup.Id);
} }
@@ -276,7 +286,8 @@ public partial class CharacterPanel
return true; return true;
var filter = SkillFilterText.Trim(); var filter = SkillFilterText.Trim();
return skill.Name.Contains(filter, StringComparison.OrdinalIgnoreCase) || skill.DiceRollDefinition.Contains(filter, StringComparison.OrdinalIgnoreCase); return skill.Name.Contains(filter, StringComparison.OrdinalIgnoreCase) ||
skill.DiceRollDefinition.Contains(filter, StringComparison.OrdinalIgnoreCase);
} }
private static string InitialsFor(string value) private static string InitialsFor(string value)
@@ -317,9 +328,13 @@ public partial class CharacterPanel
private bool IsD6Ruleset => RulesetFormHelpers.IsD6(SelectedCampaignRulesetId); private bool IsD6Ruleset => RulesetFormHelpers.IsD6(SelectedCampaignRulesetId);
private bool IsRolemasterRuleset => RulesetFormHelpers.IsRolemaster(SelectedCampaignRulesetId); private bool IsRolemasterRuleset => RulesetFormHelpers.IsRolemaster(SelectedCampaignRulesetId);
private bool IsSkillGroupRolemasterOpenEnded => RulesetFormHelpers.IsRolemasterOpenEndedExpression(SkillGroupState.Model.DiceRollDefinition);
private string SkillGroupExpressionHelpText => IsRolemasterRuleset ? $"{RulesetFormHelpers.RolemasterExampleText()}. Negative modifiers are allowed." : "Enter the default expression for skills created in this group."; private bool IsSkillGroupRolemasterOpenEnded =>
RulesetFormHelpers.IsRolemasterOpenEndedExpression(SkillGroupState.Model.DiceRollDefinition);
private string SkillGroupExpressionHelpText => IsRolemasterRuleset
? $"{RulesetFormHelpers.RolemasterExampleText()}. Negative modifiers are allowed."
: "Enter the default expression for skills created in this group.";
private bool ShowCreateSkillModal { get; set; } private bool ShowCreateSkillModal { get; set; }
private bool ShowEditSkillModal { get; set; } private bool ShowEditSkillModal { get; set; }
@@ -335,78 +350,55 @@ public partial class CharacterPanel
private bool IsSubmittingSkillGroup { get; set; } private bool IsSubmittingSkillGroup { get; set; }
private string SkillFilterText { get; set; } = string.Empty; private string SkillFilterText { get; set; } = string.Empty;
[Inject] [Inject] private RpgRollerApiClient ApiClient { get; set; } = null!;
private RpgRollerApiClient ApiClient { get; set; } = null!;
[Parameter] [Parameter] public bool IsCampaignDataLoading { get; set; }
public bool IsCampaignDataLoading { get; set; }
[Parameter] [Parameter] public CampaignRoster? SelectedCampaign { get; set; }
public CampaignRoster? SelectedCampaign { get; set; }
[Parameter] [Parameter] public Guid? SelectedCharacterId { get; set; }
public Guid? SelectedCharacterId { get; set; }
[Parameter] [Parameter] public CharacterSummary? SelectedCharacter { get; set; }
public CharacterSummary? SelectedCharacter { get; set; }
[Parameter] [Parameter] public bool IsMutating { get; set; }
public bool IsMutating { get; set; }
[Parameter] [Parameter] public IReadOnlyList<CharacterSheetSkill> SelectedCharacterSkills { get; set; } = [];
public IReadOnlyList<CharacterSheetSkill> SelectedCharacterSkills { get; set; } = [];
[Parameter] [Parameter] public IReadOnlyList<CharacterSheetSkillGroup> SelectedCharacterSkillGroups { get; set; } = [];
public IReadOnlyList<CharacterSheetSkillGroup> SelectedCharacterSkillGroups { get; set; } = [];
[Parameter] [Parameter] public string SelectedCampaignRulesetId { get; set; } = string.Empty;
public string SelectedCampaignRulesetId { get; set; } = string.Empty;
[Parameter] [Parameter] public string RollVisibility { get; set; } = "public";
public string RollVisibility { get; set; } = "public";
[Parameter] [Parameter] public bool EnableInteractiveControls { get; set; }
public EventCallback<string> RollVisibilityChanged { get; set; }
[Parameter] [Parameter] public EventCallback<string> RollVisibilityChanged { get; set; }
public Func<Guid, string> OwnerLabel { get; set; } = _ => string.Empty;
[Parameter] [Parameter] public Func<Guid, string> OwnerLabel { get; set; } = _ => string.Empty;
public Func<CharacterSheetSkill, string> SkillDefinitionLabel { get; set; } = _ => string.Empty;
[Parameter] [Parameter] public Func<CharacterSheetSkill, string> SkillDefinitionLabel { get; set; } = _ => string.Empty;
public Func<CharacterSummary, bool> CanEditCharacter { get; set; } = _ => false;
[Parameter] [Parameter] public Func<CharacterSummary, bool> CanEditCharacter { get; set; } = _ => false;
public Func<CharacterSheetSkill, bool> CanEditSkill { get; set; } = _ => false;
[Parameter] [Parameter] public Func<CharacterSheetSkill, bool> CanEditSkill { get; set; } = _ => false;
public EventCallback<Guid> CharacterSelected { get; set; }
[Parameter] [Parameter] public EventCallback<Guid> CharacterSelected { get; set; }
public EventCallback<CharacterSummary> EditCharacterRequested { get; set; }
[Parameter] [Parameter] public EventCallback<CharacterSummary> EditCharacterRequested { get; set; }
public EventCallback<Guid> SkillCreated { get; set; }
[Parameter] [Parameter] public EventCallback<Guid> SkillCreated { get; set; }
public EventCallback<Guid> SkillUpdated { get; set; }
[Parameter] [Parameter] public EventCallback<Guid> SkillUpdated { get; set; }
public EventCallback<Guid> SkillGroupCreated { get; set; }
[Parameter] [Parameter] public EventCallback<Guid> SkillGroupCreated { get; set; }
public EventCallback<Guid> SkillGroupUpdated { get; set; }
[Parameter] [Parameter] public EventCallback<Guid> SkillGroupUpdated { get; set; }
public EventCallback<Guid> SkillDeleted { get; set; }
[Parameter] [Parameter] public EventCallback<Guid> SkillDeleted { get; set; }
public EventCallback<Guid> SkillGroupDeleted { get; set; }
[Parameter] [Parameter] public EventCallback<Guid> SkillGroupDeleted { get; set; }
public EventCallback<string> ErrorOccurred { get; set; }
[Parameter] [Parameter] public EventCallback<string> ErrorOccurred { get; set; }
public EventCallback<CharacterSheetSkill> RollRequested { get; set; }
[Parameter] public EventCallback<CharacterSheetSkill> RollRequested { get; set; }
} }

View File

@@ -1,14 +1,5 @@
@using RpgRoller.Components.Pages.HomeControls @using RpgRoller.Components.Pages.HomeControls
<div class="@State.AppCssClass"> <div class="@State.AppCssClass">
@if (!IsInitialized)
{
<main class="loading-shell" aria-busy="true" aria-live="polite">
<h1>RpgRoller</h1>
<p>Loading workspace...</p>
</main>
}
else
{
<p class="sr-only" aria-live="polite">@State.LiveAnnouncement</p> <p class="sr-only" aria-live="polite">@State.LiveAnnouncement</p>
@if (State.HasHealthIssue) @if (State.HasHealthIssue)
@@ -50,6 +41,7 @@
SelectedCharacterSkillGroups="State.PlaySelectedCharacterSkillGroups" SelectedCharacterSkillGroups="State.PlaySelectedCharacterSkillGroups"
SelectedCampaignRulesetId="@(State.PlaySelectedCampaign?.RulesetId ?? string.Empty)" SelectedCampaignRulesetId="@(State.PlaySelectedCampaign?.RulesetId ?? string.Empty)"
RollVisibility="State.RollVisibility" RollVisibility="State.RollVisibility"
EnableInteractiveControls="EnableCharacterControls"
RollVisibilityChanged="Session.OnRollVisibilityChangedAsync" RollVisibilityChanged="Session.OnRollVisibilityChangedAsync"
OwnerLabel="State.OwnerLabel" OwnerLabel="State.OwnerLabel"
SkillDefinitionLabel="State.SkillDefinitionLabel" SkillDefinitionLabel="State.SkillDefinitionLabel"
@@ -75,6 +67,7 @@
SelectedCharacterName="@(State.PlaySelectedCharacter?.Name)" SelectedCharacterName="@(State.PlaySelectedCharacter?.Name)"
SelectedCampaignRulesetId="@(State.PlaySelectedCampaign?.RulesetId ?? string.Empty)" SelectedCampaignRulesetId="@(State.PlaySelectedCampaign?.RulesetId ?? string.Empty)"
RollVisibility="State.RollVisibility" RollVisibility="State.RollVisibility"
EnableCustomRollComposer="EnableCustomRollComposer"
IsMutating="State.IsMutating" IsMutating="State.IsMutating"
ToggleRollDetailRequested="Play.ToggleRollDetailAsync" ToggleRollDetailRequested="Play.ToggleRollDetailAsync"
ResolveRollDetail="Play.ResolveRollDetail" ResolveRollDetail="Play.ResolveRollDetail"
@@ -191,12 +184,9 @@
} }
</div> </div>
} }
}
</div> </div>
@if (IsInitialized) <CharacterFormModal
{
<CharacterFormModal
Visible="State.ShowCreateCharacterModal" Visible="State.ShowCreateCharacterModal"
Title="Create Character" Title="Create Character"
SubmitLabel="Create Character" SubmitLabel="Create Character"
@@ -213,7 +203,7 @@
CharacterSaved="Campaigns.OnCharacterCreatedAsync" CharacterSaved="Campaigns.OnCharacterCreatedAsync"
CancelRequested="Campaigns.CloseCharacterModals"/> CancelRequested="Campaigns.CloseCharacterModals"/>
<CharacterFormModal <CharacterFormModal
Visible="State.ShowEditCharacterModal" Visible="State.ShowEditCharacterModal"
Title="Edit Character" Title="Edit Character"
SubmitLabel="Save Character" SubmitLabel="Save Character"
@@ -230,7 +220,7 @@
CharacterSaved="Campaigns.OnCharacterUpdatedAsync" CharacterSaved="Campaigns.OnCharacterUpdatedAsync"
CancelRequested="Campaigns.CloseCharacterModals"/> CancelRequested="Campaigns.CloseCharacterModals"/>
<RolemasterSkillRollModal <RolemasterSkillRollModal
Visible="State.ShowRolemasterSkillRollModal" Visible="State.ShowRolemasterSkillRollModal"
SkillName="@(State.PendingRolemasterSkillRoll?.Name ?? string.Empty)" SkillName="@(State.PendingRolemasterSkillRoll?.Name ?? string.Empty)"
Expression="@(State.PendingRolemasterSkillRoll?.DiceRollDefinition ?? string.Empty)" Expression="@(State.PendingRolemasterSkillRoll?.DiceRollDefinition ?? string.Empty)"
@@ -241,4 +231,3 @@
IsSubmitting="State.IsSubmittingRolemasterSkillRoll" IsSubmitting="State.IsSubmittingRolemasterSkillRoll"
ConfirmRequested="Play.SubmitRolemasterSkillRollAsync" ConfirmRequested="Play.SubmitRolemasterSkillRollAsync"
CancelRequested="Play.CancelRolemasterSkillRollAsync"/> CancelRequested="Play.CancelRolemasterSkillRollAsync"/>
}

View File

@@ -12,11 +12,28 @@ public partial class Workspace : IAsyncDisposable
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnAfterRenderAsync(bool firstRender)
{ {
State.HasInteractiveRenderStarted = true; State.HasInteractiveRenderStarted = true;
if (!firstRender) if (firstRender)
{
await Session.InitializeAsync();
HasSessionInitialized = true;
await InvokeAsync(StateHasChanged);
return;
}
if (!HasSessionInitialized)
return; return;
await Session.InitializeAsync(); if (!EnableCharacterControls)
IsInitialized = true; {
EnableCharacterControls = true;
await InvokeAsync(StateHasChanged);
return;
}
if (EnableCustomRollComposer)
return;
EnableCustomRollComposer = true;
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
} }
@@ -98,8 +115,10 @@ public partial class Workspace : IAsyncDisposable
[Parameter] public EventCallback<string?> LoggedOut { get; set; } [Parameter] public EventCallback<string?> LoggedOut { get; set; }
private bool IsInitialized { get; set; }
private WorkspaceState State { get; } = new(); private WorkspaceState State { get; } = new();
private bool HasSessionInitialized { get; set; }
private bool EnableCharacterControls { get; set; }
private bool EnableCustomRollComposer { get; set; }
private WorkspaceCampaignScopeCoordinator Scope => m_Scope ??= new(State, Feedback, JS, WorkspaceQuery, private WorkspaceCampaignScopeCoordinator Scope => m_Scope ??= new(State, Feedback, JS, WorkspaceQuery,
Play.EnsureSelectedCharacterActiveAsync, Play.RefreshSelectedCharacterSheetAsync, Play.RefreshCampaignLogAsync, Play.EnsureSelectedCharacterActiveAsync, Play.RefreshSelectedCharacterSheetAsync, Play.RefreshCampaignLogAsync,

View File

@@ -91,6 +91,89 @@ test("successful login transitions to play workspace", async ({ page, context })
await expect(page.locator("#login-username")).toHaveCount(0); await expect(page.locator("#login-username")).toHaveCount(0);
}); });
test("workspace stays usable when input controls are DOM-wrapped during mount", async ({ page, context }) => {
const username = `wrapped-${Date.now()}`;
await registerAndLogin(context.request, username, "Wrapped Inputs");
const campaign = await postJson(context.request, "/api/campaigns", {
name: "Wrapped Inputs",
rulesetId: "d6"
});
const character = await postJson(context.request, "/api/characters", {
name: "Wrapper Hero",
campaignId: campaign.id
});
await postJson(context.request, `/api/characters/${character.id}/skills`, {
name: "Stealth",
diceRollDefinition: "2D+1",
wildDice: 1,
allowFumble: true
});
await page.addInitScript(() => {
const wrappedMarker = "rrWrappedByTest";
function wrapControl(element) {
if (!(element instanceof HTMLElement) || !element.isConnected || element.dataset[wrappedMarker] === "1") {
return;
}
const parent = element.parentNode;
if (!parent) {
return;
}
const wrapper = document.createElement("span");
wrapper.dataset[wrappedMarker] = "1";
element.dataset[wrappedMarker] = "1";
parent.insertBefore(wrapper, element);
wrapper.appendChild(element);
}
function queueWrap(node) {
if (!(node instanceof Element)) {
return;
}
if (node.matches("input, select")) {
queueMicrotask(() => wrapControl(node));
}
node.querySelectorAll("input, select").forEach((element) => {
queueMicrotask(() => wrapControl(element));
});
}
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach(queueWrap);
});
});
observer.observe(document.documentElement, { childList: true, subtree: true });
document.querySelectorAll("input, select").forEach((element) => queueWrap(element));
});
const blazorErrors = [];
page.on("console", (message) => {
if (message.type() !== "error") {
return;
}
const text = message.text();
if (/error applying batch|unhandled exception on the current circuit/i.test(text)) {
blazorErrors.push(text);
}
});
await page.goto("/");
await expect(page.getByText("Campaign Log")).toBeVisible();
await expect(page.locator("#skill-filter-input")).toBeVisible();
await expect(page.locator("#custom-roll-expression")).toBeVisible();
await expect(page.getByRole("button", { name: "Roll Stealth" })).toBeVisible();
expect(blazorErrors).toEqual([]);
});
test("Rolemaster open-ended roll detail renders specialized dice chips", async ({ page, context }) => { test("Rolemaster open-ended roll detail renders specialized dice chips", async ({ page, context }) => {
const username = `rm-${Date.now()}`; const username = `rm-${Date.now()}`;
const displayName = "Rolemaster Smoke"; const displayName = "Rolemaster Smoke";