Add user manager page (#22)

Reviewed-on: #22
This commit was merged in pull request #22.
This commit is contained in:
2025-04-27 20:49:57 +02:00
parent 033747899b
commit a2e93c9438
63 changed files with 1249 additions and 135 deletions

View File

@@ -0,0 +1,34 @@
@using Blazored.FluentValidation
@using GameIdeas.BlazorApp.Shared.Components.Select
@using GameIdeas.BlazorApp.Shared.Components.Select.Models
@using GameIdeas.BlazorApp.Shared.Constants
@using GameIdeas.Shared.Dto
<EditForm EditContext="EditContext" OnSubmit="HandleSubmitClicked">
<FluentValidationValidator Validator="Validator" />
<div class="row">
<div class="icon">
@Icons.Account
</div>
<div class="name">
<InputText class="input-name" @bind-Value="@User.Username" disabled="@(!IsEditing)" />
</div>
<div class="password">
<InputText type="password" class="input-password" placeholder="********" @bind-Value="@User.Password" disabled="@(!IsEditing)" />
</div>
<div class="role">
<Select TItem="RoleDto" THeader="object" DisableClicked=!IsEditing Type="SelectType.Single"
Theme="SelectTheme.Single" Values="Roles" ValuesChanged="HandleValuesChanged" Params="SelectRoleParams">
<span class="role-label @(!IsEditing ? "disabled" : "")">@(User.Role?.Name ?? ResourcesKey.Unknown)</span>
</Select>
</div>
<div class="buttons">
<button type="button" class="remove" @onclick="HandleRemoveClicked">@Icons.Bin</button>
@if (CanEdit)
{
<button type="button" class="edit @(IsEditing ? "selected" : "")" @onclick="HandleEditClicked">@Icons.Pen</button>
}
<button type="submit" class="submit" style="display: @(IsEditing ? "block" : "none");">@Icons.Check</button>
</div>
</div>
</EditForm>

View File

@@ -0,0 +1,81 @@
using FluentValidation;
using GameIdeas.BlazorApp.Shared.Components.Select;
using GameIdeas.BlazorApp.Shared.Components.Select.Models;
using GameIdeas.Shared.Dto;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Forms;
namespace GameIdeas.BlazorApp.Pages.Users.Components;
public partial class UserRow
{
[Parameter] public UserDto User { get; set; } = new();
[Parameter] public List<RoleDto> Roles { get; set; } = [];
[Parameter] public bool CanEdit { get; set; } = true;
[Parameter] public bool IsEditing { get; set; } = false;
[Parameter] public EventCallback<UserDto> OnRemove { get; set; }
[Parameter] public EventCallback<UserDto> OnSubmit { get; set; }
[Parameter] public IValidator Validator { get; set; } = default!;
private SelectParams<RoleDto, object> SelectRoleParams = new();
private EditContext? EditContext;
private UserDto? OriginalUser;
protected override void OnInitialized()
{
EditContext = new(User);
OriginalUser = new()
{
Username = User.Username,
Role = User.Role
};
base.OnInitialized();
}
protected override void OnParametersSet()
{
SelectRoleParams = new()
{
GetItemLabel = role => role.Name,
Items = Roles
};
base.OnParametersSet();
}
private void HandleValuesChanged(IEnumerable<RoleDto> roles)
{
User.Role = roles.FirstOrDefault();
}
private async Task HandleSubmitClicked()
{
if (EditContext?.Validate() == false)
{
return;
}
IsEditing = false;
await OnSubmit.InvokeAsync(User);
User.Password = null;
}
private void HandleEditClicked()
{
if (IsEditing)
{
User.Username = OriginalUser?.Username;
User.Role = OriginalUser?.Role;
User.Password = null;
}
IsEditing = !IsEditing;
}
private async Task HandleRemoveClicked()
{
await OnRemove.InvokeAsync(User);
}
}

View File

@@ -0,0 +1,91 @@
.row {
height: 64px;
display: grid;
grid-template-columns: 48px 1fr 1fr 1fr auto;
grid-gap: 8px;
padding: 0 8px;
background: var(--input-secondary);
box-shadow: var(--drop-shadow);
border-radius: var(--big-radius);
}
.row > * {
align-content: center;
}
.icon ::deep svg {
fill: var(--line);
}
.role {
min-width: 160px;
width: fit-content;
}
::deep .input-name,
::deep .input-name[disabled],
::deep .input-password,
::deep .input-password[disabled],
::deep .input-password::placeholder {
color: var(--white);
}
::deep .input-name,
::deep .input-password,
.role-label {
border: none;
outline: none;
background: none;
height: 28px;
padding: 0 10px;
box-sizing: border-box;
border-radius: var(--small-radius);
border: solid 1px var(--line);
background: rgb(0, 0, 0, 0.2);
}
::deep .input-name[disabled],
::deep .input-password[disabled],
.disabled {
border: none;
background: none;
}
.role-label {
display: block;
align-content: center;
}
.buttons {
display: flex;
flex-direction: row;
gap: 8px;
height:auto;
}
.buttons > * {
border: none;
outline: none;
margin: auto;
height: 28px;
width: 28px;
background: none;
border-radius: var(--small-radius);
padding: 2px;
}
.buttons > *:hover, .selected {
background: var(--input-selected)
}
.remove ::deep svg {
fill: var(--red);
}
.edit ::deep svg {
fill: var(--yellow);
}
.submit ::deep svg {
fill: var(--green);
}

View File

@@ -0,0 +1,12 @@
@using GameIdeas.BlazorApp.Shared.Constants
<div class="row">
<div class="icon">@Icons.Account</div>
<div class="name pill"></div>
<div class="password pill"></div>
<div class="role pill"></div>
<div class="buttons">
<span class="remove">@Icons.Bin</span>
<span class="edit">@Icons.Pen</span>
</div>
</div>

View File

@@ -0,0 +1,69 @@
.row {
height: 64px;
display: grid;
grid-template-columns: 48px 1fr 1fr 1fr auto;
grid-gap: 8px;
padding: 0 8px;
background: var(--input-secondary);
box-shadow: var(--drop-shadow);
border-radius: var(--big-radius);
align-content: center;
}
.icon {
height: 48px;
width: 48px;
}
.icon ::deep svg {
fill: var(--line);
}
.pill {
animation: loading 3s ease infinite;
align-self: center;
height: 28px;
border-radius: var(--small-radius);
box-sizing: border-box;
max-width: 180px;
}
.buttons {
display: flex;
flex-direction: row;
gap: 8px;
height: auto;
width: auto;
}
.buttons > * {
margin: auto;
height: 28px;
width: 28px;
background: none;
border-radius: var(--small-radius);
padding: 2px;
box-sizing: border-box;
}
.remove ::deep svg {
fill: var(--red);
}
.edit ::deep svg {
fill: var(--yellow);
}
@keyframes loading {
0% {
background: rgb(255, 255, 255, 0.05);
}
50% {
background: rgb(255, 255, 255, 0.2);
}
100% {
background: rgb(255, 255, 255, 0.05);
}
}

View File

@@ -0,0 +1,50 @@
using FluentValidation;
using GameIdeas.Resources;
using GameIdeas.Shared.Dto;
namespace GameIdeas.BlazorApp.Pages.Users.Components;
public class UserCreateValidator : AbstractValidator<UserDto>
{
public UserCreateValidator()
{
RuleFor(user => user.Username)
.NotEmpty()
.WithMessage(ResourcesKey.MissingField);
RuleFor(user => user.Password)
.NotEmpty()
.WithMessage(ResourcesKey.MissingField);
RuleFor(user => user.Role)
.NotEmpty()
.WithMessage(ResourcesKey.MissingField);
}
}
public class UserUpdateValidator : AbstractValidator<UserDto>
{
public UserUpdateValidator()
{
When(user => user.Password == null && user.Role == null, () =>
{
RuleFor(user => user.Username)
.NotEmpty()
.WithMessage(ResourcesKey.MissingField);
});
When(user => user.Username == null && user.Role == null, () =>
{
RuleFor(user => user.Password)
.NotEmpty()
.WithMessage(ResourcesKey.MissingField);
});
When(user => user.Username == null && user.Password == null, () =>
{
RuleFor(user => user.Role)
.NotEmpty()
.WithMessage(ResourcesKey.MissingField);
});
}
}

View File

@@ -0,0 +1,9 @@
using GameIdeas.Shared.Dto;
namespace GameIdeas.BlazorApp.Pages.Users.Filters;
public class UserFilterParams
{
public string? Name { get; set; }
public List<RoleDto>? Roles { get; set; }
}

View File

@@ -0,0 +1,13 @@
using GameIdeas.BlazorApp.Pages.Users.Filters;
using GameIdeas.Shared.Dto;
namespace GameIdeas.BlazorApp.Pages.Users.Gateways;
public interface IUserGateway
{
Task<UserListDto> GetUsers(UserFilterParams filterParams, int currentPage);
Task<IEnumerable<RoleDto>> GetRoles();
Task<IdDto> CreateUser(UserDto user);
Task<IdDto> UpdateUser(UserDto user);
Task<IdDto> DeleteUser(string userId);
}

View File

@@ -0,0 +1,84 @@
using GameIdeas.BlazorApp.Pages.Users.Filters;
using GameIdeas.BlazorApp.Services;
using GameIdeas.BlazorApp.Shared.Constants;
using GameIdeas.BlazorApp.Shared.Exceptions;
using GameIdeas.Resources;
using GameIdeas.Shared.Dto;
namespace GameIdeas.BlazorApp.Pages.Users.Gateways;
public class UserGateway(IHttpClientService httpClient) : IUserGateway
{
public async Task<IdDto> CreateUser(UserDto user)
{
try
{
return await httpClient.PostAsync<IdDto>(Endpoints.User.Create, user)
?? throw new InvalidOperationException(ResourcesKey.ErrorCreateUser);
}
catch (Exception)
{
throw new UserCreationException(ResourcesKey.ErrorCreateUser);
}
}
public async Task<IdDto> DeleteUser(string userId)
{
try
{
return await httpClient.DeleteAsync<IdDto>(Endpoints.User.Delete(userId))
?? throw new InvalidOperationException(ResourcesKey.ErrorDeleteUser);
}
catch (Exception)
{
throw new UserCreationException(ResourcesKey.ErrorDeleteUser);
}
}
public async Task<IEnumerable<RoleDto>> GetRoles()
{
try
{
return await httpClient.FetchDataAsync<IEnumerable<RoleDto>>(Endpoints.User.Roles)
?? throw new InvalidOperationException(ResourcesKey.ErrorFetchRoles);
}
catch (Exception)
{
throw new RoleNotFoundException(ResourcesKey.ErrorFetchRoles);
}
}
public async Task<UserListDto> GetUsers(UserFilterParams filterParams, int currentPage)
{
try
{
UserFilterDto filter = new()
{
CurrentPage = currentPage,
Name = filterParams.Name,
RoleIds = filterParams.Roles?.Select(r => r.Id)
};
var url = Endpoints.User.Fetch(filter);
return await httpClient.FetchDataAsync<UserListDto>(url)
?? throw new InvalidOperationException(ResourcesKey.ErrorFetchUsers);
}
catch (Exception)
{
throw new UserNotFoundException(ResourcesKey.ErrorFetchUsers);
}
}
public async Task<IdDto> UpdateUser(UserDto user)
{
try
{
return await httpClient.PutAsync<IdDto>(Endpoints.User.Update(user.Id ?? string.Empty), user)
?? throw new InvalidOperationException(ResourcesKey.ErrorUpdateUser);
}
catch (Exception)
{
throw new UserCreationException(ResourcesKey.ErrorUpdateUser);
}
}
}

View File

@@ -0,0 +1,49 @@
@page "/Users"
@using GameIdeas.BlazorApp.Pages.Games.Header
@using GameIdeas.BlazorApp.Layouts
@using GameIdeas.BlazorApp.Pages.Users.Components
@using GameIdeas.BlazorApp.Shared.Components.Popup
@using GameIdeas.BlazorApp.Shared.Components.Popup.Components
@using GameIdeas.BlazorApp.Shared.Components.Search
@using GameIdeas.BlazorApp.Shared.Components.Select.Models
@using GameIdeas.BlazorApp.Shared.Components.SelectSearch
@using GameIdeas.Shared.Dto
@layout MainLayout
<PageTitle>@ResourcesKey.GamesIdeas</PageTitle>
<GameHeader DisplayAdd="false">
<div class="header-content">
<SearchInput Placeholder="@ResourcesKey.EnterUsername" @bind-Text="FilterParams.Name" @bind-Text:after=HandleFilterChanged />
<SelectSearch TItem="RoleDto" Placeholder="@ResourcesKey.Roles" @bind-Values="FilterParams.Roles" @bind-Values:after=HandleFilterChanged
Items="Roles.ToList()" GetLabel="@(role => role.Name)" Theme="SelectTheme.Filter" />
</div>
</GameHeader>
<div class="container">
<UserRow User="UserAdd" Roles="Roles.ToList()" CanEdit="false" IsEditing="true" OnRemove="HandleResetUser" OnSubmit="HandleSubmitUser" Validator="@(new UserCreateValidator())" />
<span class="line"></span>
<div class="content">
@if (!IsLoading)
{
@foreach (var user in UserList.Users ?? [])
{
<UserRow User="user" Roles="Roles.ToList()" OnRemove="HandleOpenConfirmationPopup" OnSubmit="HandleUpdateUser" Validator="@(new UserUpdateValidator())" />
}
}
else
{
@for (int i = 0; i < 20; i++)
{
<UserRowSkeleton />
}
}
</div>
</div>
<Popup @ref=Popup Closable=false>
<ConfirmDelete OnCancel="HandleCancelPopupClicked" OnConfirm="HandleRemoveUser" />
</Popup>

View File

@@ -0,0 +1,133 @@
using GameIdeas.BlazorApp.Pages.Users.Filters;
using GameIdeas.BlazorApp.Pages.Users.Gateways;
using GameIdeas.BlazorApp.Shared.Components.Popup;
using GameIdeas.Shared.Dto;
using Microsoft.AspNetCore.Components;
namespace GameIdeas.BlazorApp.Pages.Users;
public partial class Users
{
[Inject] private IUserGateway UserGateway { get; set; } = default!;
private Popup? Popup;
private bool IsLoading = false;
private UserFilterParams FilterParams = new();
private UserListDto UserList = new();
private IEnumerable<RoleDto> Roles = [];
private int CurrentPage = 1;
private UserDto UserAdd = new();
private UserDto? UserDelete;
protected override async Task OnInitializedAsync()
{
await FetchData();
await base.OnInitializedAsync();
}
private async Task FetchData(bool fetchRoles = true)
{
try
{
IsLoading = true;
if (fetchRoles)
Roles = await UserGateway.GetRoles();
UserList = await UserGateway.GetUsers(FilterParams, CurrentPage);
}
catch (Exception)
{
throw;
}
finally
{
IsLoading = false;
}
}
private async Task HandleSubmitUser(UserDto user)
{
try
{
IsLoading = true;
await UserGateway.CreateUser(user);
await FetchData(false);
}
catch (Exception)
{
throw;
}
finally
{
IsLoading = false;
}
UserAdd = new();
}
private async Task HandleUpdateUser(UserDto user)
{
try
{
IsLoading = true;
await UserGateway.UpdateUser(user);
await FetchData(false);
}
catch (Exception)
{
throw;
}
finally
{
IsLoading = false;
}
}
private async Task HandleRemoveUser()
{
Popup?.Close();
if (UserDelete?.Id == null)
{
return;
}
try
{
IsLoading = true;
await UserGateway.DeleteUser(UserDelete.Id);
await FetchData(false);
}
catch (Exception)
{
throw;
}
finally
{
IsLoading = false;
}
UserDelete = null;
}
private void HandleResetUser(UserDto args)
{
UserAdd = new();
}
private async Task HandleFilterChanged()
{
await FetchData(false);
}
private void HandleCancelPopupClicked()
{
Popup?.Close();
}
private void HandleOpenConfirmationPopup(UserDto user)
{
UserDelete = user;
Popup?.Open();
}
}

View File

@@ -0,0 +1,32 @@
.header-content {
display: flex;
flex-direction: row;
gap: 8px;
}
::deep .search-container, ::deep .select-container {
box-sizing: border-box;
max-width: 200px;
}
.container {
padding: 20px 200px;
display: grid;
grid-gap: 20px;
}
.line {
border: 1px solid var(--line);
}
.content {
display: flex;
flex-direction: column;
gap: 20px;
}
@media screen and (max-width: 1000px) {
.container {
padding: 20px 20px;
}
}