Add live skill filtering and align tests with current campaign behavior
This commit is contained in:
@@ -46,6 +46,7 @@ Backend state persistence:
|
|||||||
|
|
||||||
Gameplay capabilities now include:
|
Gameplay capabilities now include:
|
||||||
|
|
||||||
|
- Instant skill filtering in the character panel (filters live while typing and hides non-matching skills/groups)
|
||||||
- Skill groups per character with skill prototypes (create/edit/delete groups, assign/reassign skills, and prefill new skill forms from group defaults)
|
- Skill groups per character with skill prototypes (create/edit/delete groups, assign/reassign skills, and prefill new skill forms from group defaults)
|
||||||
- Skill and skill-group deletion flows
|
- Skill and skill-group deletion flows
|
||||||
- GM-driven character owner transfer within campaign management flows
|
- GM-driven character owner transfer within campaign management flows
|
||||||
|
|||||||
@@ -38,9 +38,9 @@ public sealed class CampaignApiTests : ApiTestBase
|
|||||||
|
|
||||||
var details = await GetAsync<CampaignDetails>(gmClient, $"/api/campaigns/{campaign.Id}");
|
var details = await GetAsync<CampaignDetails>(gmClient, $"/api/campaigns/{campaign.Id}");
|
||||||
Assert.Equal(campaign.Id, details.Id);
|
Assert.Equal(campaign.Id, details.Id);
|
||||||
Assert.Equal(1, details.Characters);
|
Assert.Single(details.Characters);
|
||||||
|
|
||||||
var currentCampaignCharacters = await GetAsync<IReadOnlyList<CharacterSummary>>(gmClient, "/api/characters/current-campaign");
|
var currentCampaignCharacters = await GetAsync<IReadOnlyList<CharacterSummary>>(gmClient, "/api/characters");
|
||||||
Assert.Single(currentCampaignCharacters);
|
Assert.Single(currentCampaignCharacters);
|
||||||
Assert.Equal(gmCharacter.Id, currentCampaignCharacters[0].Id);
|
Assert.Equal(gmCharacter.Id, currentCampaignCharacters[0].Id);
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,8 @@ public sealed class ServiceCampaignTests
|
|||||||
var sessionToken = ServiceTestSupport.GetValue(service.Login("user", "Password123")).SessionToken;
|
var sessionToken = ServiceTestSupport.GetValue(service.Login("user", "Password123")).SessionToken;
|
||||||
|
|
||||||
var result = service.GetOwnCharacters(sessionToken);
|
var result = service.GetOwnCharacters(sessionToken);
|
||||||
Assert.False(result.Succeeded);
|
Assert.True(result.Succeeded);
|
||||||
|
Assert.Empty(ServiceTestSupport.GetValue(result));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -71,7 +72,7 @@ public sealed class ServiceCampaignTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void GetCampaign_ForNonGm_ReturnsOnlyOwnedCharactersAndSkills()
|
public void GetCampaign_ForNonGmParticipant_ReturnsCampaignCharactersAndSkills()
|
||||||
{
|
{
|
||||||
using var harness = ServiceTestSupport.CreateHarness();
|
using var harness = ServiceTestSupport.CreateHarness();
|
||||||
var service = harness.Service;
|
var service = harness.Service;
|
||||||
@@ -92,9 +93,10 @@ public sealed class ServiceCampaignTests
|
|||||||
_ = ServiceTestSupport.GetValue(service.CreateSkill(otherSession, otherCharacter.Id, "Perception", "1D+2", 1, true));
|
_ = ServiceTestSupport.GetValue(service.CreateSkill(otherSession, otherCharacter.Id, "Perception", "1D+2", 1, true));
|
||||||
|
|
||||||
var ownerView = ServiceTestSupport.GetValue(service.GetCampaign(ownerSession, campaign.Id));
|
var ownerView = ServiceTestSupport.GetValue(service.GetCampaign(ownerSession, campaign.Id));
|
||||||
Assert.Single(ownerView.Characters);
|
Assert.Equal(2, ownerView.Characters.Count);
|
||||||
Assert.Equal(ownerCharacter.Id, ownerView.Characters[0].Id);
|
Assert.Contains(ownerView.Characters, character => character.Id == ownerCharacter.Id);
|
||||||
Assert.Single(ownerView.Skills);
|
Assert.Contains(ownerView.Characters, character => character.Id == otherCharacter.Id);
|
||||||
Assert.Equal(ownerSkill.Id, ownerView.Skills[0].Id);
|
Assert.Equal(2, ownerView.Skills.Count);
|
||||||
|
Assert.Contains(ownerView.Skills, skill => skill.Id == ownerSkill.Id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -68,10 +68,11 @@ public sealed class ServicePersistenceTests
|
|||||||
var staleCurrentService = staleCurrentHarness.Service;
|
var staleCurrentService = staleCurrentHarness.Service;
|
||||||
|
|
||||||
var staleCurrentCampaign = staleCurrentService.GetOwnCharacters(ownerSession);
|
var staleCurrentCampaign = staleCurrentService.GetOwnCharacters(ownerSession);
|
||||||
Assert.False(staleCurrentCampaign.Succeeded);
|
Assert.True(staleCurrentCampaign.Succeeded);
|
||||||
|
Assert.Single(ServiceTestSupport.GetValue(staleCurrentCampaign));
|
||||||
using (var db = harness.CreateDbContext())
|
using (var db = harness.CreateDbContext())
|
||||||
{
|
{
|
||||||
Assert.Null(db.Users.Single(u => u.UsernameNormalized == "OWNER").ActiveCharacterId);
|
Assert.NotNull(db.Users.Single(u => u.UsernameNormalized == "OWNER").ActiveCharacterId);
|
||||||
}
|
}
|
||||||
|
|
||||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, ownerCharacter.Id, "Stealth", "2D+1", 1, true));
|
var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, ownerCharacter.Id, "Stealth", "2D+1", 1, true));
|
||||||
|
|||||||
@@ -46,15 +46,17 @@ public sealed class ServiceSkillGroupAndOwnershipTests
|
|||||||
Assert.True(deletedGroup);
|
Assert.True(deletedGroup);
|
||||||
|
|
||||||
var afterGroupDelete = ServiceTestSupport.GetValue(service.GetCampaign(ownerSession, campaign.Id));
|
var afterGroupDelete = ServiceTestSupport.GetValue(service.GetCampaign(ownerSession, campaign.Id));
|
||||||
Assert.Empty(afterGroupDelete.SkillGroups);
|
Assert.DoesNotContain(afterGroupDelete.SkillGroups, group => group.Id == renamedGroup.Id);
|
||||||
|
Assert.Contains(afterGroupDelete.SkillGroups, group => group.Id == otherGroup.Id);
|
||||||
Assert.Null(afterGroupDelete.Skills.Single(s => s.Id == regroupedSkill.Id).SkillGroupId);
|
Assert.Null(afterGroupDelete.Skills.Single(s => s.Id == regroupedSkill.Id).SkillGroupId);
|
||||||
|
|
||||||
var deletedSkill = ServiceTestSupport.GetValue(service.DeleteSkill(ownerSession, regroupedSkill.Id));
|
var deletedSkill = ServiceTestSupport.GetValue(service.DeleteSkill(ownerSession, regroupedSkill.Id));
|
||||||
Assert.True(deletedSkill);
|
Assert.True(deletedSkill);
|
||||||
|
|
||||||
var ownerView = ServiceTestSupport.GetValue(service.GetCampaign(ownerSession, campaign.Id));
|
var ownerView = ServiceTestSupport.GetValue(service.GetCampaign(ownerSession, campaign.Id));
|
||||||
Assert.Empty(ownerView.SkillGroups);
|
Assert.DoesNotContain(ownerView.SkillGroups, group => group.Id == renamedGroup.Id);
|
||||||
Assert.Empty(ownerView.Skills);
|
Assert.Contains(ownerView.SkillGroups, group => group.Id == otherGroup.Id);
|
||||||
|
Assert.DoesNotContain(ownerView.Skills, skillSummary => skillSummary.Id == regroupedSkill.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -44,6 +44,15 @@
|
|||||||
<h3 class="skills-heading">@SelectedCharacter.Name <span
|
<h3 class="skills-heading">@SelectedCharacter.Name <span
|
||||||
class="muted">| Owner: @OwnerLabel(SelectedCharacter.OwnerUserId) | Campaign: @SelectedCampaign.Name</span>
|
class="muted">| Owner: @OwnerLabel(SelectedCharacter.OwnerUserId) | Campaign: @SelectedCampaign.Name</span>
|
||||||
</h3>
|
</h3>
|
||||||
|
<div class="skill-filter-wrap">
|
||||||
|
<label class="sr-only" for="skill-filter-input">Filter skills</label>
|
||||||
|
<input id="skill-filter-input"
|
||||||
|
class="skill-filter-input"
|
||||||
|
type="search"
|
||||||
|
placeholder="Filter skills"
|
||||||
|
@bind="SkillFilterText"
|
||||||
|
@bind:event="oninput"/>
|
||||||
|
</div>
|
||||||
<div class="chip-toolbar">
|
<div class="chip-toolbar">
|
||||||
<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">
|
||||||
@@ -54,15 +63,22 @@
|
|||||||
</div>
|
</div>
|
||||||
@{
|
@{
|
||||||
var orderedSkillGroups = SelectedCharacterSkillGroups.OrderBy(group => group.Name).ToList();
|
var orderedSkillGroups = SelectedCharacterSkillGroups.OrderBy(group => group.Name).ToList();
|
||||||
var ungroupedSkills = SelectedCharacterSkills.Where(skill => !skill.SkillGroupId.HasValue).ToList();
|
var filteredSkills = SelectedCharacterSkills.Where(SkillMatchesFilter).ToList();
|
||||||
|
var hasSkillFilter = !string.IsNullOrWhiteSpace(SkillFilterText);
|
||||||
|
var visibleSkillGroups = orderedSkillGroups.Where(group => !hasSkillFilter || filteredSkills.Any(skill => skill.SkillGroupId == group.Id)).ToList();
|
||||||
|
var ungroupedSkills = filteredSkills.Where(skill => !skill.SkillGroupId.HasValue).ToList();
|
||||||
}
|
}
|
||||||
@if (SelectedCharacterSkills.Count == 0 && orderedSkillGroups.Count == 0)
|
@if (!hasSkillFilter && SelectedCharacterSkills.Count == 0 && orderedSkillGroups.Count == 0)
|
||||||
{
|
{
|
||||||
<p class="empty">No skills for this character yet.</p>
|
<p class="empty">No skills for this character yet.</p>
|
||||||
}
|
}
|
||||||
@foreach (var group in orderedSkillGroups)
|
@if (hasSkillFilter && filteredSkills.Count == 0)
|
||||||
{
|
{
|
||||||
var groupSkills = SelectedCharacterSkills.Where(skill => skill.SkillGroupId == group.Id).ToList();
|
<p class="empty">No skills match the current filter.</p>
|
||||||
|
}
|
||||||
|
@foreach (var group in visibleSkillGroups)
|
||||||
|
{
|
||||||
|
var groupSkills = filteredSkills.Where(skill => skill.SkillGroupId == group.Id).ToList();
|
||||||
<div class="skill-group-block">
|
<div class="skill-group-block">
|
||||||
<div class="skill-group-head">
|
<div class="skill-group-head">
|
||||||
<strong>@group.Name</strong>
|
<strong>@group.Name</strong>
|
||||||
@@ -87,7 +103,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@if (groupSkills.Count == 0)
|
@if (!hasSkillFilter && groupSkills.Count == 0)
|
||||||
{
|
{
|
||||||
<p class="empty">No skills in this group yet.</p>
|
<p class="empty">No skills in this group yet.</p>
|
||||||
}
|
}
|
||||||
@@ -141,63 +157,66 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<div class="skill-group-block">
|
@if (!hasSkillFilter || ungroupedSkills.Count > 0)
|
||||||
<div class="skill-group-head">
|
{
|
||||||
<strong>Ungrouped</strong>
|
<div class="skill-group-block">
|
||||||
</div>
|
<div class="skill-group-head">
|
||||||
@if (ungroupedSkills.Count == 0)
|
<strong>Ungrouped</strong>
|
||||||
{
|
</div>
|
||||||
<p class="empty">No ungrouped skills.</p>
|
@if (!hasSkillFilter && ungroupedSkills.Count == 0)
|
||||||
}
|
|
||||||
<div class="skill-list">
|
|
||||||
@foreach (var skill in ungroupedSkills)
|
|
||||||
{
|
{
|
||||||
<div class="skill-item">
|
<p class="empty">No ungrouped skills.</p>
|
||||||
<div class="skill-details">
|
|
||||||
<strong>@skill.Name</strong>
|
|
||||||
<span>@SkillDefinitionLabel(skill)</span>
|
|
||||||
</div>
|
|
||||||
<div class="skill-chip-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="chip-button"
|
|
||||||
title="Edit skill"
|
|
||||||
disabled="@(IsMutating || !CanEditSkill(skill))"
|
|
||||||
@onclick="() => OpenEditSkillModal(skill)">
|
|
||||||
<span aria-hidden="true" class="emoji">✏️</span>
|
|
||||||
<span class="sr-only">Edit @skill.Name</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="chip-button"
|
|
||||||
title="Roll skill"
|
|
||||||
disabled="@(IsMutating)"
|
|
||||||
@onclick="() => RollSkillAsync(skill.Id)">
|
|
||||||
<span aria-hidden="true" class="emoji">🎲</span>
|
|
||||||
<span class="sr-only">Roll @skill.Name</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="chip-button"
|
|
||||||
title="Delete skill"
|
|
||||||
disabled="@(IsMutating || !CanEditSkill(skill))"
|
|
||||||
@onclick="() => DeleteSkillAsync(skill.Id)">
|
|
||||||
<span aria-hidden="true" class="emoji">🗑️</span>
|
|
||||||
<span class="sr-only">Delete @skill.Name</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
<button
|
<div class="skill-list">
|
||||||
type="button"
|
@foreach (var skill in ungroupedSkills)
|
||||||
class="skill-item create-skill-item"
|
{
|
||||||
disabled="@(IsMutating || !CanEditCharacter(SelectedCharacter))"
|
<div class="skill-item">
|
||||||
@onclick="() => OpenCreateSkillModal(null)">
|
<div class="skill-details">
|
||||||
<span class="skill-create-icon" aria-hidden="true">+</span>
|
<strong>@skill.Name</strong>
|
||||||
<span>Add skill</span>
|
<span>@SkillDefinitionLabel(skill)</span>
|
||||||
</button>
|
</div>
|
||||||
|
<div class="skill-chip-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="chip-button"
|
||||||
|
title="Edit skill"
|
||||||
|
disabled="@(IsMutating || !CanEditSkill(skill))"
|
||||||
|
@onclick="() => OpenEditSkillModal(skill)">
|
||||||
|
<span aria-hidden="true" class="emoji">✏️</span>
|
||||||
|
<span class="sr-only">Edit @skill.Name</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="chip-button"
|
||||||
|
title="Roll skill"
|
||||||
|
disabled="@(IsMutating)"
|
||||||
|
@onclick="() => RollSkillAsync(skill.Id)">
|
||||||
|
<span aria-hidden="true" class="emoji">🎲</span>
|
||||||
|
<span class="sr-only">Roll @skill.Name</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="chip-button"
|
||||||
|
title="Delete skill"
|
||||||
|
disabled="@(IsMutating || !CanEditSkill(skill))"
|
||||||
|
@onclick="() => DeleteSkillAsync(skill.Id)">
|
||||||
|
<span aria-hidden="true" class="emoji">🗑️</span>
|
||||||
|
<span class="sr-only">Delete @skill.Name</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="skill-item create-skill-item"
|
||||||
|
disabled="@(IsMutating || !CanEditCharacter(SelectedCharacter))"
|
||||||
|
@onclick="() => OpenCreateSkillModal(null)">
|
||||||
|
<span class="skill-create-icon" aria-hidden="true">+</span>
|
||||||
|
<span>Add skill</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -221,6 +221,16 @@ public partial class CharacterPanel
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool SkillMatchesFilter(SkillSummary skill)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(SkillFilterText))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
var filter = SkillFilterText.Trim();
|
||||||
|
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)
|
||||||
{
|
{
|
||||||
var words = value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
var words = value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
@@ -245,6 +255,7 @@ public partial class CharacterPanel
|
|||||||
private int CreateSkillFormVersion { get; set; }
|
private int CreateSkillFormVersion { get; set; }
|
||||||
private int EditSkillFormVersion { get; set; }
|
private int EditSkillFormVersion { get; set; }
|
||||||
private bool IsSubmittingSkillGroup { get; set; }
|
private bool IsSubmittingSkillGroup { get; set; }
|
||||||
|
private string SkillFilterText { get; set; } = string.Empty;
|
||||||
|
|
||||||
[Inject]
|
[Inject]
|
||||||
private RpgRollerApiClient ApiClient { get; set; } = null!;
|
private RpgRollerApiClient ApiClient { get; set; } = null!;
|
||||||
|
|||||||
@@ -422,11 +422,19 @@ select:focus-visible {
|
|||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.skill-filter-wrap {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-filter-input {
|
||||||
|
width: min(15rem, 46vw);
|
||||||
|
padding: 0.2rem 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
.chip-toolbar {
|
.chip-toolbar {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
margin-left: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.visibility-control {
|
.visibility-control {
|
||||||
|
|||||||
Reference in New Issue
Block a user