Skip to content

Commit

Permalink
Feature/issue vouchers (#16)
Browse files Browse the repository at this point in the history
* UI for issuing vouchers

* Repositories for products and vouchers

* Services

* Access to backend through API

* Adds product fetch error handling

* Show voucher responses

* Add error handling to vouchers

* Add count of tickets to product

* Adds form validation

* Adds Issue Vouchers to navigation menu

* Add role based access

* Copy to clipboard

* Readonly textfield

* Center div

* Refactor from Rider suggestions

* Redirect to main page if unauthorized

* Addresses reviews

* Remove deprecated link

* Improved form validation

* Small UI changes

* Adds autocomplete searchable dropdown and prefix length counter

---------

Co-authored-by: Andreas Trøstrup <8415722+Duckth@users.noreply.github.com>
  • Loading branch information
A-Guldborg and duckth authored Nov 6, 2023
1 parent 7de6d87 commit dcc9fe6
Show file tree
Hide file tree
Showing 15 changed files with 404 additions and 5 deletions.
173 changes: 173 additions & 0 deletions Shifty.App/Components/Voucher.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
@namespace Components
@using System.ComponentModel.DataAnnotations
@using Shifty.App.Services
@using Shifty.Api.Generated.AnalogCoreV1
@using Shifty.Api.Generated.AnalogCoreV2
@using Shared
@using LanguageExt.UnsafeValueAccess
@inject IProductService _productService
@inject IVoucherService _voucherService
@inject ISnackbar Snackbar
@inject IJSRuntime JSRuntime

<MudContainer Style="margin: 50px 15%;" >
<MudCard Class="mb-auto" MinWidth="280px" Width="40vw">
<MudCardContent>
<MudText Align="Align.Center" Class="mb-n4">Issue Voucher Form</MudText>
<MudForm @bind-IsValid="@_isFormValid" >
<MudAutocomplete T="ProductDto"
Required="true"
RequiredError="Product is required"
Placeholder="Select product"
ResetValueOnEmptyText="true"
CoerceText="true"
CoerceValue="true"
Label="Product"
SearchFunc="@Products"
ToStringFunc="@_converter"
@bind-Value=_voucherForm.Product/>

<MudNumericField @bind-Value="_voucherForm.Amount"
Placeholder="1"
Label="Amount"
Variant="Variant.Text"
Required="true"
RequiredError="Product is required"
Min="1"
Max="50" />

<MudTextField T="string"
@bind-Value="_voucherForm.Requester"
Label="Requester"
Required="true"
RequiredError="Requester is required" />

<MudTextField T="string"
@bind-Value="_voucherForm.Description"
Label="Description"
Required="true"
RequiredError="Description is required" />

<MudTextField T="string"
@bind-Value="_voucherForm.Prefix"
Label="Voucher prefix"
Validation="@(new Func<string,string>(prefixValidation))"
Required="true"
Counter="3"
MaxLength="3"
Immediate="true"
RequiredError="Prefix is required" />

<MudCardActions>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
Class="ml-auto"
Disabled="@(!_isFormValid)"
OnClick="@(async () => await IssueVoucher())"
EndIcon="@Icons.Material.Filled.Sell">
Issue Voucher
</MudButton>
</MudCardActions>
</MudForm>
@if (_showProgressBar)
{
<MudContainer class="d-flex">
<LoadingIndicator Height="100px"/>
</MudContainer>
}
@if (_vouchers is not null)
{
<MudTextField Text="@_voucherCodes"
@ref="_multilineReference"
T="string"
Adornment="Adornment.End"
Style="border-width: 2px; padding: 4px;"
Outlined="true"
AdornmentIcon="@Icons.Material.Outlined.ContentCopy"
OnAdornmentClick="@(async () => await CopyToClipboard())"
Lines="@Math.Min(_vouchers.Count(), 10)"
ReadOnly=true />
}
</MudCardContent>
</MudCard>
</MudContainer>

@code
{
[Parameter]
public System.Security.Claims.ClaimsPrincipal User { get; set; }
private VoucherForm _voucherForm = new();
private bool _isFormValid = false;
private bool _showProgressBar = false;
private IEnumerable<ProductDto> _products = new List<ProductDto>();
private IEnumerable<IssueVoucherResponse> _vouchers;
private string _voucherCodes;
private MudTextField<string> _multilineReference;

private class VoucherForm
{
[Required]
public string Description { get; set; }
public ProductDto Product { get; set; }
public int Amount { get; set; } = 1;
public string Requester { get; set; }
public string Prefix { get; set; }
}

protected override async Task OnInitializedAsync()
{
Snackbar.Configuration.PositionClass = Defaults.Classes.Position.BottomRight;
var result = await _productService.GetProducts();

result.Match(
Succ: products => {
_products = products;
_voucherForm.Prefix = User.Claims.Single(el => el.Type.Contains("email")).Value[..3].ToUpper();
},
Fail: error => {
Snackbar.Add(error.Message, Severity.Error);
}
);
}

private async Task<IEnumerable<ProductDto>> Products(string value)

Check warning on line 133 in Shifty.App/Components/Voucher.razor

View workflow job for this annotation

GitHub Actions / dev-deploy / Build codebase / Build webapp / Build and test Webapp

This async method lacks 'await' operators and will run synchronously. Consider using the 'await' operator to await non-blocking API calls, or 'await Task.Run(...)' to do CPU-bound work on a background thread.

Check warning on line 133 in Shifty.App/Components/Voucher.razor

View workflow job for this annotation

GitHub Actions / dev-deploy / Build codebase / Build webapp / Build and test Webapp

This async method lacks 'await' operators and will run synchronously. Consider using the 'await' operator to await non-blocking API calls, or 'await Task.Run(...)' to do CPU-bound work on a background thread.

Check warning on line 133 in Shifty.App/Components/Voucher.razor

View workflow job for this annotation

GitHub Actions / prd-deploy / Build codebase / Build webapp / Build and test Webapp

This async method lacks 'await' operators and will run synchronously. Consider using the 'await' operator to await non-blocking API calls, or 'await Task.Run(...)' to do CPU-bound work on a background thread.

Check warning on line 133 in Shifty.App/Components/Voucher.razor

View workflow job for this annotation

GitHub Actions / prd-deploy / Build codebase / Build webapp / Build and test Webapp

This async method lacks 'await' operators and will run synchronously. Consider using the 'await' operator to await non-blocking API calls, or 'await Task.Run(...)' to do CPU-bound work on a background thread.
{
if (string.IsNullOrEmpty(value))
return _products;
return _products.Where(x => x.Name.StartsWith(value, StringComparison.InvariantCultureIgnoreCase));
}

private async Task CopyToClipboard()
{
await JSRuntime.InvokeVoidAsync("navigator.clipboard.writeText", _voucherCodes);
Snackbar.Add("Codes copied to clipboard", Severity.Success);
}

private async Task IssueVoucher()
{
_showProgressBar = true;
_vouchers = null;
_voucherCodes = null;
var result = await _voucherService.IssueVouchers(
amount: _voucherForm.Amount,
productId: _voucherForm.Product.Id,
description: _voucherForm.Description,
requester: _voucherForm.Requester,
prefix: _voucherForm.Prefix);

result.Match(
Succ: response => {
_showProgressBar = false;
_vouchers = response;
_voucherCodes = string.Join("\n", _vouchers.Select(response => response.VoucherCode));
},
Fail: ex => {
_showProgressBar = false;
Snackbar.Add(ex.Message, Severity.Error);
}
);
}

Func<ProductDto,string> _converter = p => p != null ? $"{p.Name} - {p.NumberOfTickets} ticket" + (p.NumberOfTickets == 1 ? "" : "s") : "";
Func<string, string> prefixValidation = str => str.Length == 3 ? null : "Prefix must be 3 letters";
}
24 changes: 24 additions & 0 deletions Shifty.App/Pages/IssueVoucher.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
@page "/Voucher"
@using Components
@inject NavigationManager NavManager

@if (_user is not null && _user.IsInRole("Board"))
{
<Voucher User="@_user"/>
}

@code {
[CascadingParameter] public Task<AuthenticationState> AuthTask { get; set; }
private System.Security.Claims.ClaimsPrincipal _user;

protected override async Task OnInitializedAsync()
{
var authState = await AuthTask;
_user = authState.User;

if (_user is null || !_user.IsInRole("Board"))
{
NavManager.NavigateTo("/");
}
}
}
1 change: 0 additions & 1 deletion Shifty.App/Pages/Login.razor
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@


@code {
bool _validInput;
bool _loggingIn = false;
bool _successfulLogin = true;
LoginForm _loginForm = new();
Expand Down
14 changes: 14 additions & 0 deletions Shifty.App/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using Microsoft.Extensions.DependencyInjection;
using MudBlazor.Services;
using Shifty.Api.Generated.AnalogCoreV1;
using Shifty.Api.Generated.AnalogCoreV2;
using Shifty.App.Authentication;
using Shifty.App.Repositories;
using Shifty.App.Services;
Expand All @@ -16,6 +17,7 @@ namespace Shifty.App
{
public class Program
{
private const string apiUrl = "https://core.dev.analogio.dk/";
public static async Task Main(string[] args)
{
var builder = WebAssemblyHostBuilder.CreateDefault(args);
Expand All @@ -42,10 +44,22 @@ public static void ConfigureServices(IServiceCollection services, IConfiguration
.AddHttpMessageHandler<RequestAuthenticationHandler>();
services.AddScoped(provider =>
new AnalogCoreV1(provider.GetRequiredService<IHttpClientFactory>().CreateClient("AnalogCoreV1")));

services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>()
.CreateClient("AnalogCoreV2"));
services.AddHttpClient("AnalogCoreV2",
client => client.BaseAddress = new Uri(configuration["ApiHost"]))
.AddHttpMessageHandler<RequestAuthenticationHandler>();
services.AddScoped(provider =>
new AnalogCoreV2(provider.GetRequiredService<IHttpClientFactory>().CreateClient("AnalogCoreV2")));
services.AddScoped<IAccountRepository, AccountRepository>();
services.AddScoped<IVoucherRepository, VoucherRepository>();
services.AddScoped<IProductRepository, ProductRepository>();
services.AddScoped<CustomAuthStateProvider>();
services.AddScoped<AuthenticationStateProvider>(s => s.GetService<CustomAuthStateProvider>());
services.AddScoped<IAuthenticationService, AuthenticationService>();
services.AddScoped<IVoucherService, VoucherService>();
services.AddScoped<IProductService, ProductService>();
services.AddScoped<RequestAuthenticationHandler>();

}
Expand Down
12 changes: 12 additions & 0 deletions Shifty.App/Repositories/IProductRepository.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using System.Threading.Tasks;
using LanguageExt;
using LanguageExt.Common;
using Shifty.Api.Generated.AnalogCoreV1;

namespace Shifty.App.Repositories
{
public interface IProductRepository
{
public Task<Try<System.Collections.Generic.IEnumerable<ProductDto>>> GetProducts();
}
}
12 changes: 12 additions & 0 deletions Shifty.App/Repositories/IVoucherRepository.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using System.Threading.Tasks;
using LanguageExt;
using LanguageExt.Common;
using Shifty.Api.Generated.AnalogCoreV2;

namespace Shifty.App.Repositories
{
public interface IVoucherRepository
{
public Task<Try<System.Collections.Generic.ICollection<IssueVoucherResponse>>> IssueAsync(int amount, int productId, string description, string requester, string prefix);
}
}
27 changes: 27 additions & 0 deletions Shifty.App/Repositories/ProductRepository.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using LanguageExt;
using LanguageExt.Common;
using Shifty.Api.Generated.AnalogCoreV1;
using Shifty.App.Services;
using static LanguageExt.Prelude;

namespace Shifty.App.Repositories
{
public class ProductRepository : IProductRepository
{
private readonly AnalogCoreV1 _client;

public ProductRepository(AnalogCoreV1 client)
{
_client = client;
}

async Task<Try<IEnumerable<ProductDto>>> IProductRepository.GetProducts()
{
return await TryAsync(async () => (await _client.ApiV1ProductsAsync()).AsEnumerable());
}
}
}
35 changes: 35 additions & 0 deletions Shifty.App/Repositories/VoucherRepository.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading.Tasks;
using LanguageExt;
using LanguageExt.Common;
using Shifty.Api.Generated.AnalogCoreV2;
using static LanguageExt.Prelude;

namespace Shifty.App.Repositories
{
public class VoucherRepository : IVoucherRepository
{
private readonly AnalogCoreV2 _client;

public VoucherRepository(AnalogCoreV2 client)
{
_client = client;
}

public async Task<Try<ICollection<IssueVoucherResponse>>> IssueAsync(int amount, int productId, string description, string reqeuster, string prefix)
{
var request = new IssueVoucherRequest()
{
Amount = amount,
Description = description,
ProductId = productId,
Requester = reqeuster,
VoucherPrefix = prefix,
};

return await TryAsync(async () => await _client.ApiV2VouchersIssueVouchersAsync(request));
}
}
}
17 changes: 17 additions & 0 deletions Shifty.App/Services/IProductService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using LanguageExt;
using LanguageExt.Common;
using Shifty.Api.Generated.AnalogCoreV1;

namespace Shifty.App.Services
{
public interface IProductService
{
/// <summary>
/// Gives products available to user
/// </summary>
/// <returns>Collection of products in the form of ProductDtos. The Collection is null if an error happens.</returns>
Task<Try<IEnumerable<ProductDto>>> GetProducts();
}
}
13 changes: 13 additions & 0 deletions Shifty.App/Services/IVoucherService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using LanguageExt;
using LanguageExt.Common;
using Shifty.Api.Generated.AnalogCoreV2;

namespace Shifty.App.Services
{
public interface IVoucherService
{
Task<Try<ICollection<IssueVoucherResponse>>> IssueVouchers(int amount, int productId, string description, string requester, string prefix);
}
}
Loading

0 comments on commit dcc9fe6

Please sign in to comment.