Implement feedback page

This commit is contained in:
Tomasi - Developing 2025-03-11 16:24:59 +01:00
parent 479eef5a21
commit bfa32ea279
19 changed files with 452 additions and 5 deletions

View File

@ -17,6 +17,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.0.0" />
<PackageReference Include="Octokit" Version="14.0.0" />
<PackageReference Include="Serilog" Version="4.1.0" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.0.0" />

View File

@ -0,0 +1,31 @@
using Application.DataTransferObjects.Feedback;
using Application.Interfaces;
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Api.Controllers.v1
{
[Route("api/v{version:apiVersion}/[controller]")]
[ApiController]
[ApiVersion("1.0")]
[Authorize]
public class FeedbacksController(ILogger<FeedbacksController> logger, IGitHubService gitHubService) : ControllerBase
{
[HttpPost]
public async Task<ActionResult<string>> PostFeedback([FromForm] FeedbackDto feedbackDto)
{
try
{
var issue = await gitHubService.CreateIssueAsync(feedbackDto);
return Ok(new {url = issue.HtmlUrl});
}
catch (Exception e)
{
logger.LogError(e, e.Message);
return StatusCode(StatusCodes.Status500InternalServerError);
}
}
}
}

View File

@ -1,5 +1,3 @@
// Configure Serilog logger
using Api.Configurations;
using Api.Middleware;
using Application;
@ -43,6 +41,11 @@ try
.ValidateDataAnnotations()
.ValidateOnStart();
builder.Services.AddOptions<GitHubSetting>()
.BindConfiguration("GitHubSettings")
.ValidateDataAnnotations()
.ValidateOnStart();
var jwtSection = builder.Configuration.GetRequiredSection("Jwt");
builder.Services.ConfigureAndAddAuthentication(jwtSection);

View File

@ -10,6 +10,7 @@
<Folder Include="DataTransferObjects\Alliance\" />
<Folder Include="Classes\" />
<Folder Include="DataTransferObjects\MarshalGuardParticipant\" />
<Folder Include="DataTransferObjects\Feedback\" />
<Folder Include="DataTransferObjects\ZombieSiegeParticipant\" />
<Folder Include="DataTransferObjects\Rank\" />
<Folder Include="DataTransferObjects\Note\" />

View File

@ -41,6 +41,7 @@ public static class ApplicationDependencyInjection
services.AddTransient<IEmailService, EmailService>();
services.AddTransient<IExcelService, ExcelService>();
services.AddTransient<IEncryptionService, EncryptionService>();
services.AddTransient<IGitHubService, GitHubService>();
return services;
}

View File

@ -0,0 +1,18 @@
using Microsoft.AspNetCore.Http;
namespace Application.DataTransferObjects.Feedback;
public class FeedbackDto
{
public required string Type { get; set; }
public required string Title { get; set; }
public required string Description { get; set; }
public string? ExpectedBehavior { get; set; }
public string? ActualBehavior { get; set; }
public string? Reproduction { get; set; }
public string? Severity { get; set; }
public string? Os { get; set; }
public required string AppVersion { get; set; }
public string? Email { get; set; }
public IFormFile? Screenshot { get; set; }
}

View File

@ -0,0 +1,9 @@
using Application.DataTransferObjects.Feedback;
using Octokit;
namespace Application.Interfaces;
public interface IGitHubService
{
public Task<Issue> CreateIssueAsync(FeedbackDto feedbackDto);
}

View File

@ -151,7 +151,8 @@ public class PlayerRepository(ApplicationContext context, IMapper mapper, ILogge
ModifiedBy = null,
DismissalReason = null,
DismissedAt = null,
IsDismissed = false
IsDismissed = false,
Id = Guid.CreateVersion7()
};
await context.Players.AddAsync(newPlayer, cancellationToken);

View File

@ -0,0 +1,70 @@
using Application.DataTransferObjects.Feedback;
using Application.Interfaces;
using Microsoft.Extensions.Options;
using Octokit;
using Utilities.Classes;
using ProductHeaderValue = Octokit.ProductHeaderValue;
namespace Application.Services;
public class GitHubService(IOptions<GitHubSetting> gitHubSettingOption) : IGitHubService
{
private readonly GitHubSetting _gitHubSetting = gitHubSettingOption.Value;
public async Task<Issue> CreateIssueAsync(FeedbackDto feedbackDto)
{
var gitHubClient = new GitHubClient(new ProductHeaderValue("PlayerManagerApi"))
{
Credentials = new Credentials(_gitHubSetting.Token)
};
var label = feedbackDto.Type == "bug" ? "bug" : "enhancement";
var issueBody = $"**Description:**\n{feedbackDto.Description}\n\n";
if (label == "bug")
{
issueBody += $"**Expected Behavior:**\n{feedbackDto.ExpectedBehavior}\n\n" +
$"**Actual Behavior:**\n{feedbackDto.ActualBehavior}\n\n" +
$"**Steps to Reproduce:**\n{feedbackDto.Reproduction}\n\n" +
$"**Severity:** {feedbackDto.Severity}\n\n" +
$"**Operating System:** {feedbackDto.Os}\n\n" +
$"**App Version:** {feedbackDto.AppVersion}\n\n";
}
if (feedbackDto.Email is not null)
{
issueBody += $"**Contact:** {feedbackDto.Email}\n\n";
}
if (feedbackDto.Screenshot is { Length: > 0 })
{
using var stream = new MemoryStream();
await feedbackDto.Screenshot.CopyToAsync(stream);
var fileBytes = stream.ToArray();
var filePath = $"screenshots/{Guid.NewGuid()}.png";
var createFileRequest = new CreateFileRequest(
"Upload Screenshot",
Convert.ToBase64String(fileBytes),
false
);
var fileResponse = await gitHubClient.Repository.Content.CreateFile(
_gitHubSetting.Owner,
_gitHubSetting.Name,
filePath,
createFileRequest
);
issueBody += $"**Screenshot:**\n\n ![Screenshot]({fileResponse.Content.DownloadUrl})";
}
var createIssue = new NewIssue($"[{feedbackDto.Type.ToUpper()}] {feedbackDto.Title}")
{
Body = issueBody
};
createIssue.Labels.Add(label);
return await gitHubClient.Issue.Create(_gitHubSetting.Owner, _gitHubSetting.Name, createIssue);
}
}

View File

@ -17,6 +17,15 @@ All notable changes to this project are documented here.
This project is currently in the **Beta Phase**.
---
### **[0.8.0]** - *2025-03-11*
#### ✨ Added
- **Feedback Page:** Users can now submit feedback, including bug reports and feature requests.
- **GitHub Integration:** Feedback is automatically created as a GitHub issue, providing a direct link to track progress.
- **Screenshot Upload:** Users can attach a screenshot to better illustrate issues.
- **Success Message:** After submission, the form hides, and a success message with the GitHub issue link is displayed.
🛠️ **Fixed**
- (N/A)
### **[0.7.0]** - *2025-02-06*
#### ✨ Added

View File

@ -24,6 +24,7 @@ import {ZombieSiegeDetailComponent} from "./pages/zombie-siege/zombie-siege-deta
import {CustomEventDetailComponent} from "./pages/custom-event/custom-event-detail/custom-event-detail.component";
import {DismissPlayerComponent} from "./pages/dismiss-player/dismiss-player.component";
import {MvpComponent} from "./pages/mvp/mvp.component";
import {FeedbackComponent} from "./pages/feedback/feedback.component";
const routes: Routes = [
{path: 'players', component: PlayerComponent, canActivate: [authGuard]},
@ -40,6 +41,7 @@ const routes: Routes = [
{ path: 'alliance', component: AllianceComponent, canActivate: [authGuard]},
{path: 'account', component: AccountComponent, canActivate: [authGuard]},
{path: 'change-password', component: ChangePasswordComponent, canActivate: [authGuard]},
{path: 'feedback', component: FeedbackComponent, canActivate: [authGuard]},
{path: 'custom-event', component: CustomEventComponent, canActivate: [authGuard]},
{path: 'custom-event-detail/:id', component: CustomEventDetailComponent, canActivate: [authGuard]},
{path: 'zombie-siege', component: ZombieSiegeComponent, canActivate: [authGuard]},

View File

@ -59,6 +59,7 @@ import {PlayerInfoVsDuelComponent} from "./pages/player-information/player-info-
import { MvpComponent } from './pages/mvp/mvp.component';
import { AllianceApiKeyComponent } from './pages/alliance/alliance-api-key/alliance-api-key.component';
import { AllianceUserAdministrationComponent } from './pages/alliance/alliance-user-administration/alliance-user-administration.component';
import { FeedbackComponent } from './pages/feedback/feedback.component';
@NgModule({
declarations: [
@ -107,7 +108,8 @@ import { AllianceUserAdministrationComponent } from './pages/alliance/alliance-u
PlayerExcelImportModalComponent,
MvpComponent,
AllianceApiKeyComponent,
AllianceUserAdministrationComponent
AllianceUserAdministrationComponent,
FeedbackComponent
],
imports: [
BrowserModule,

View File

@ -78,6 +78,7 @@
<div ngbDropdownMenu>
<button (click)="isShown = false" routerLink="/account" ngbDropdownItem>Account</button>
<button (click)="isShown = false" routerLink="/change-password" ngbDropdownItem>Change password</button>
<button (click)="isShown = false" routerLink="/feedback" ngbDropdownItem>Submit Feedback</button>
<div class="dropdown-divider"></div>
<button (click)="onLogout()" ngbDropdownItem>Logout</button>
</div>

View File

@ -0,0 +1,164 @@
<div class="container mt-3 pb-5">
<h2 class="text-center">Submit Feedback</h2>
@if (!isSubmitted) {
<!-- Feedback Form -->
<form [formGroup]="feedbackForm">
<div class="form-floating mb-3 is-invalid">
<select [ngClass]="{
'is-valid': f['type'].valid,
'is-invalid': f['type'].invalid && (f['type'].touched || f['type'].dirty)
}" class="form-control" id="type" formControlName="type" (change)="onTypeChange($event)">
<option value="bug">Bug Report</option>
<option value="feature">Feature Request</option>
</select>
<label for="type">Feedback Type</label>
</div>
<div class="form-floating mb-3 is-invalid">
<input [ngClass]="{
'is-valid': f['title'].valid,
'is-invalid': f['title'].invalid && (f['title'].touched || f['title'].dirty)
}" type="text" class="form-control" id="title" placeholder="Title" formControlName="title">
<label for="title">Title</label>
@if (f['title'].invalid && (f['title'].touched || f['title'].dirty)) {
<div class="invalid-feedback">
@if (f['title'].hasError('required')) {
<p>Title is required</p>
}
</div>
}
</div>
<div class="form-floating mb-3 is-invalid">
<textarea [ngClass]="{
'is-valid': f['description'].valid,
'is-invalid': f['description'].invalid && (f['description'].touched || f['description'].dirty)
}" class="form-control" id="description" formControlName="description" placeholder="Describe the issue or feature" style="height: 100px;"></textarea>
<label for="description">Description</label>
@if (f['description'].invalid && (f['description'].touched || f['description'].dirty)) {
<div class="invalid-feedback">
@if (f['description'].hasError('required')) {
<p>Description is required</p>
}
</div>
}
</div>
<div class="form-floating mb-3 is-invalid">
<input type="email" class="form-control" id="email" formControlName="email" placeholder="Enter your email (optional)">
<label for="email">Your Email (Optional)</label>
</div>
@if (feedbackType === 'bug') {
<div class="form-floating mb-3 is-invalid">
<textarea [ngClass]="{
'is-valid': f['expectedBehavior'].valid,
'is-invalid': f['expectedBehavior'].invalid && (f['expectedBehavior'].touched || f['expectedBehavior'].dirty)
}" class="form-control" id="expectedBehavior" formControlName="expectedBehavior" placeholder="What did you expect to happen?" style="height: 100px;"></textarea>
<label for="expectedBehavior">Expected Behavior</label>
@if (f['expectedBehavior'].invalid && (f['expectedBehavior'].touched || f['expectedBehavior'].dirty)) {
<div class="invalid-feedback">
@if (f['expectedBehavior'].hasError('required')) {
<p>ExpectedBehavior is required</p>
}
</div>
}
</div>
<div class="form-floating mb-3 is-invalid">
<textarea [ngClass]="{
'is-valid': f['actualBehavior'].valid,
'is-invalid': f['actualBehavior'].invalid && (f['actualBehavior'].touched || f['actualBehavior'].dirty)
}" class="form-control" id="actualBehavior" formControlName="actualBehavior" placeholder="What actually happened?" style="height: 100px;"></textarea>
<label for="actualBehavior">Actual Behavior</label>
@if (f['actualBehavior'].invalid && (f['actualBehavior'].touched || f['actualBehavior'].dirty)) {
<div class="invalid-feedback">
@if (f['actualBehavior'].hasError('required')) {
<p>ActualBehavior is required</p>
}
</div>
}
</div>
<div class="form-floating mb-3 is-invalid">
<textarea [ngClass]="{
'is-valid': f['reproduction'].valid,
'is-invalid': f['reproduction'].invalid && (f['reproduction'].touched || f['reproduction'].dirty)
}" class="form-control" id="reproduction" formControlName="reproduction" placeholder="List the steps to reproduce the issue" style="height: 100px;"></textarea>
<label for="reproduction">Steps to Reproduce</label>
@if (f['reproduction'].invalid && (f['reproduction'].touched || f['reproduction'].dirty)) {
<div class="invalid-feedback">
@if (f['reproduction'].hasError('required')) {
<p>Reproduction is required</p>
}
</div>
}
</div>
<div class="form-floating mb-3 is-invalid">
<select [ngClass]="{
'is-valid': f['severity'].valid,
'is-invalid': f['severity'].invalid && (f['severity'].touched || f['severity'].dirty)
}" class="form-control" id="severity" formControlName="severity">
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
<label for="severity">Severity</label>
</div>
<div class="form-floating mb-3 is-invalid">
<select [ngClass]="{
'is-valid': f['os'].valid,
'is-invalid': f['os'].invalid && (f['os'].touched || f['os'].dirty)
}" class="form-control" id="os" formControlName="os">
<option value="windows">Windows</option>
<option value="macos">macOS</option>
<option value="linux">Linux</option>
<option value="android">Android</option>
<option value="ios">iOS</option>
</select>
<label for="os">Operating System</label>
@if (f['os'].invalid && (f['os'].touched || f['os'].dirty)) {
<div class="invalid-feedback">
@if (f['os'].hasError('required')) {
<p>Operating System is required</p>
}
</div>
}
</div>
<div class="form-floating mb-3 is-invalid">
<input [ngClass]="{
'is-valid': f['appVersion'].valid,
'is-invalid': f['appVersion'].invalid && (f['appVersion'].touched || f['appVersion'].dirty)
}" type="text" class="form-control" id="appVersion" formControlName="appVersion" placeholder="App version (e.g., 1.2.3)" readonly>
<label for="appVersion">App Version</label>
</div>
<div class="mb-3">
<label for="formFile" class="form-label">Screenshot (optional):</label>
<input class="form-control" type="file" id="formFile" (change)="onFileChange($event)">
</div>
}
<div class="d-flex justify-content-between">
<button (click)="onCancel()" type="button" class="btn btn-warning">Cancel</button>
<button [disabled]="feedbackForm.invalid" (click)="onSubmit()" type="submit" class="btn btn-success">Send feedback</button>
</div>
</form>
} @else {
<div class="alert alert-success text-center">
<h3>Thank you for your feedback!</h3>
<p>Your feedback has been successfully submitted.</p>
<p>
You can track the progress of your feedback <br />
<a [href]="issueUrl" target="_blank" class="btn btn-success mt-2">View Issue on GitHub</a>
</p>
</div>
}
</div>

View File

@ -0,0 +1,101 @@
import {Component, inject, OnInit} from '@angular/core';
import {FormBuilder, FormGroup, Validators} from "@angular/forms";
import {environment} from "../../../environments/environment";
import {FeedbackService} from "../../services/feedback.service";
import {ToastrService} from "ngx-toastr";
import {Router} from "@angular/router";
@Component({
selector: 'app-feedback',
templateUrl: './feedback.component.html',
styleUrl: './feedback.component.css'
})
export class FeedbackComponent implements OnInit {
private readonly _fb: FormBuilder = inject(FormBuilder);
private readonly _feedbackService: FeedbackService = inject(FeedbackService);
private readonly _toastr: ToastrService = inject(ToastrService);
private readonly _router: Router = inject(Router);
public feedbackForm!: FormGroup;
public feedbackType: string = 'feature';
public isSubmitted: boolean = false;
public issueUrl: string = '';
private appVersion: string = environment.version;
get f() {
return this.feedbackForm.controls;
}
ngOnInit() {
this.createForm();
}
createForm() {
this.feedbackForm = this._fb.group({
type: [this.feedbackType, Validators.required],
title: ['', Validators.required],
description: ['', Validators.required],
appVersion: [this.appVersion],
email: ['', Validators.email],
screenshot: [null]
});
}
onTypeChange(event: any) {
const type = event.target.value;
this.feedbackType = type;
if (type === 'bug') {
this.feedbackForm.addControl('expectedBehavior', this._fb.control('', Validators.required));
this.feedbackForm.addControl('actualBehavior', this._fb.control('', Validators.required));
this.feedbackForm.addControl('reproduction', this._fb.control('', Validators.required));
this.feedbackForm.addControl('severity', this._fb.control('medium', Validators.required));
this.feedbackForm.addControl('os', this._fb.control('', Validators.required));
} else {
this.feedbackForm.removeControl('expectedBehavior');
this.feedbackForm.removeControl('actualBehavior');
this.feedbackForm.removeControl('reproduction');
this.feedbackForm.removeControl('severity');
this.feedbackForm.removeControl('os');
}
}
onSubmit() {
if (this.feedbackForm.valid) {
const formData = new FormData();
Object.keys(this.feedbackForm.value).forEach((key) => {
formData.append(key, this.feedbackForm.value[key]);
});
if (this.feedbackForm.get('screenshot')?.value) {
formData.append('screenshot', this.feedbackForm.get('screenshot')?.value);
}
this._feedbackService.submitFeedback(formData).subscribe({
next: ((response) => {
if (response) {
this.isSubmitted = true;
this.issueUrl = response.url;
} else {
this._toastr.error('Failure submitted feedback', 'Feedback');
}
}),
error: (error) => {
console.log(error);
this._toastr.error('Failure submitted feedback', 'Feedback');
}
});
}
}
onFileChange(event: any) {
if (event.target.files && event.target.files.length > 0) {
this.feedbackForm.patchValue({ screenshot: event.target.files[0] });
}
}
onCancel() {
this._router.navigate(['/']).then();
}
}

View File

@ -0,0 +1,18 @@
import {inject, Injectable} from '@angular/core';
import {environment} from "../../environments/environment";
import {HttpClient} from "@angular/common/http";
import {Observable} from "rxjs";
@Injectable({
providedIn: 'root'
})
export class FeedbackService {
private readonly _serviceUrl = environment.apiBaseUrl + 'Feedbacks/';
private readonly _httpClient: HttpClient = inject(HttpClient);
public submitFeedback(formData: FormData): Observable<{url:string}> {
return this._httpClient.post<{url:string}>(this._serviceUrl, formData);
}
}

View File

@ -0,0 +1,15 @@
using System.ComponentModel.DataAnnotations;
namespace Utilities.Classes;
public class GitHubSetting
{
[Required]
public required string Owner { get; set; }
[Required]
public required string Name { get; set; }
[Required]
public required string Token { get; set; }
}

View File

@ -8,7 +8,6 @@
<ItemGroup>
<Folder Include="Constants\" />
<Folder Include="Classes\" />
</ItemGroup>
<ItemGroup>
@ -16,6 +15,7 @@
<PackageReference Include="Microsoft.AspNetCore.Http.Features" Version="5.0.17" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.0" />
<PackageReference Include="Octokit" Version="14.0.0" />
</ItemGroup>
</Project>