diff --git a/QuizApp.Api/QuizApiClient.cs b/QuizApp.Api/QuizApiClient.cs index d7316e8..dfa5a39 100644 --- a/QuizApp.Api/QuizApiClient.cs +++ b/QuizApp.Api/QuizApiClient.cs @@ -107,6 +107,24 @@ public partial interface IQuizApiClient /// A server side error occurred. System.Threading.Tasks.Task> GetAllQuestionsEndpointAsync(System.Threading.CancellationToken cancellationToken); + /// Success + /// A server side error occurred. + System.Threading.Tasks.Task> GetQuestionsByQuizIdEndpointAsync(int quizId); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// Success + /// A server side error occurred. + System.Threading.Tasks.Task> GetQuestionsByQuizIdEndpointAsync(int quizId, System.Threading.CancellationToken cancellationToken); + + /// Success + /// A server side error occurred. + System.Threading.Tasks.Task SaveOrUpdateQuestionAsync(SaveOrUpdateQuestionRequest saveOrUpdateQuestionRequest); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// Success + /// A server side error occurred. + System.Threading.Tasks.Task SaveOrUpdateQuestionAsync(SaveOrUpdateQuestionRequest saveOrUpdateQuestionRequest, System.Threading.CancellationToken cancellationToken); + } [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.2.0.0 (NJsonSchema v11.1.0.0 (Newtonsoft.Json v13.0.0.0))")] @@ -892,6 +910,175 @@ public virtual async System.Threading.Tasks.Task DeleteQuizEndpointAsync(D } } + /// Success + /// A server side error occurred. + public virtual System.Threading.Tasks.Task> GetQuestionsByQuizIdEndpointAsync(int quizId) + { + return GetQuestionsByQuizIdEndpointAsync(quizId, System.Threading.CancellationToken.None); + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// Success + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task> GetQuestionsByQuizIdEndpointAsync(int quizId, System.Threading.CancellationToken cancellationToken) + { + if (quizId == null) + throw new System.ArgumentNullException("quizId"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "question/getquestionbyquizid" + urlBuilder_.Append("question/getquestionbyquizid"); + urlBuilder_.Append('?'); + urlBuilder_.Append(System.Uri.EscapeDataString("QuizId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(quizId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + urlBuilder_.Length--; + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync>(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + { + var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// Success + /// A server side error occurred. + public virtual System.Threading.Tasks.Task SaveOrUpdateQuestionAsync(SaveOrUpdateQuestionRequest saveOrUpdateQuestionRequest) + { + return SaveOrUpdateQuestionAsync(saveOrUpdateQuestionRequest, System.Threading.CancellationToken.None); + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// Success + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task SaveOrUpdateQuestionAsync(SaveOrUpdateQuestionRequest saveOrUpdateQuestionRequest, System.Threading.CancellationToken cancellationToken) + { + if (saveOrUpdateQuestionRequest == null) + throw new System.ArgumentNullException("saveOrUpdateQuestionRequest"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + var json_ = Newtonsoft.Json.JsonConvert.SerializeObject(saveOrUpdateQuestionRequest, JsonSerializerSettings); + var content_ = new System.Net.Http.StringContent(json_); + content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); + request_.Content = content_; + request_.Method = new System.Net.Http.HttpMethod("POST"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "question/savequestions" + urlBuilder_.Append("question/savequestions"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + { + var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + protected struct ObjectResponseResult { public ObjectResponseResult(T responseObject, string responseText) diff --git a/QuizApp.Client/Pages/AddQuestion.razor b/QuizApp.Client/Pages/AddQuestion.razor new file mode 100644 index 0000000..aa4ee1e --- /dev/null +++ b/QuizApp.Client/Pages/AddQuestion.razor @@ -0,0 +1,90 @@ +@page "/add-question/{quizId:int}" +@using QuizApp.Api +@using QuizApp.Common.DTO +@using System.Collections.ObjectModel +@inject IQuizApiClient apiClient +@inject DialogService DialogService +@inject NotificationService NotificationService +@inject NavigationManager NavigationManager + +

+ Add Questions & Answers +

+ + + + Add question + + + + + + + + + + + Save + + + +@code { + [Parameter] public int QuizId { get; set; } + + private ObservableCollection Questions = new ObservableCollection(); + + private async Task SaveQuestion() + { + var result = await apiClient.SaveOrUpdateQuestionAsync(new Common.Request.SaveOrUpdateQuestionRequest + { + QuizId = QuizId, + QuestionDTOs = Questions, + }); + + if(result > 0) + { + NotificationService.Notify(NotificationSeverity.Success, "Save questions successfully"); + } + } + + private async Task AddNewQuestion() + { + DialogService.OnClose += AddNewQuestionClosed; + await DialogService.OpenAsync("Add New Question"); + } + + private void AddNewQuestionClosed(dynamic obj) + { + var questions = obj as QuestionDTO; + if (questions is not null) + { + Questions.Add(questions); + StateHasChanged(); + } + } + + protected override async Task OnInitializedAsync() + { + try + { + var questionDTOs = await apiClient.GetQuestionsByQuizIdEndpointAsync(QuizId); + Questions = new ObservableCollection(questionDTOs); + } + catch(Exception ex) + { + + } + } +} diff --git a/QuizApp.Client/Pages/AddQuestionDialog.razor b/QuizApp.Client/Pages/AddQuestionDialog.razor new file mode 100644 index 0000000..c63aaa8 --- /dev/null +++ b/QuizApp.Client/Pages/AddQuestionDialog.razor @@ -0,0 +1,49 @@ +@using QuizApp.Common.DTO +@using System.Collections.ObjectModel +@inject DialogService dialogService + + + + + + + + + + + + + + + + + Add Answer + Save + + +@code { + private QuestionDTO TheQuestion = new QuestionDTO(); + private ObservableCollection Answers { get; set; } = new ObservableCollection(); + + private async Task AddAnswer() + { + Answers.Add(new AnswerDTO { Text = string.Empty, IsCorrect = false }); + } + + private void RemoveAnswer(AnswerDTO answer) + { + Answers.Remove(answer); + } + + private void Save() + { + TheQuestion.Answers = Answers; + dialogService.Close(TheQuestion); + } +} diff --git a/QuizApp.Client/Pages/QuizPage.razor b/QuizApp.Client/Pages/QuizPage.razor index ecba345..c74e4eb 100644 --- a/QuizApp.Client/Pages/QuizPage.razor +++ b/QuizApp.Client/Pages/QuizPage.razor @@ -5,6 +5,7 @@ @inject IQuizApiClient apiClient @inject DialogService DialogService @inject NotificationService NotificationService +@inject NavigationManager NavigationManager

Quiz Manager

@@ -20,7 +21,7 @@ else if (!Quizzes.Any()) } else { - + @@ -43,6 +44,14 @@ else Quizzes = new ObservableCollection(a); } + private void OnRowClick(DataGridRowMouseEventArgs selectedItem) + { + if (selectedItem.Data?.Id.HasValue == true) + { + NavigationManager.NavigateTo($"/add-question/{selectedItem.Data.Id.Value}"); + } + } + private async Task AddNewQuiz() { DialogService.OnClose += AddNewQuizClosed; diff --git a/QuizApp.Common/Request/GetQuestionByQuizIdRequest.cs b/QuizApp.Common/Request/GetQuestionByQuizIdRequest.cs new file mode 100644 index 0000000..72dd2fa --- /dev/null +++ b/QuizApp.Common/Request/GetQuestionByQuizIdRequest.cs @@ -0,0 +1,6 @@ +namespace QuizApp.Common.Request; + +public class GetQuestionByQuizIdRequest +{ + public int QuizId { get; set; } +} diff --git a/QuizApp.Common/Request/SaveOrUpdateQuestionRequest.cs b/QuizApp.Common/Request/SaveOrUpdateQuestionRequest.cs new file mode 100644 index 0000000..497615d --- /dev/null +++ b/QuizApp.Common/Request/SaveOrUpdateQuestionRequest.cs @@ -0,0 +1,10 @@ +using QuizApp.Common.DTO; + +namespace QuizApp.Common.Request; + +public class SaveOrUpdateQuestionRequest +{ + public int QuizId { get; set; } + public int QuestionId { get; set; } + public IEnumerable QuestionDTOs { get; set; } +} diff --git a/QuizApp.Database/Repositories/IQuizRepository.cs b/QuizApp.Database/Repositories/IQuizRepository.cs index 4a96fe2..2f328d7 100644 --- a/QuizApp.Database/Repositories/IQuizRepository.cs +++ b/QuizApp.Database/Repositories/IQuizRepository.cs @@ -7,7 +7,9 @@ public interface IQuizRepository Task DeleteQuiz(int quizId); Task SaveOrUpdateQuiz(QuizDTO quizDTO); Task SaveOrUpdateQuestion(QuestionDTO questionDto); + Task SaveOrUpdateQuestion(IEnumerable questionDTOs, int quizId); Task> GetQuestions(); Task> GetAllQuizzes(); + Task> GetQuestionsByQuizId(int quizId); Task SaveSubmissionAsync(int userId, int quizId, IEnumerable userAnswers); } \ No newline at end of file diff --git a/QuizApp.Database/Repositories/QuizRepository.cs b/QuizApp.Database/Repositories/QuizRepository.cs index cfea40b..00b97d5 100644 --- a/QuizApp.Database/Repositories/QuizRepository.cs +++ b/QuizApp.Database/Repositories/QuizRepository.cs @@ -118,6 +118,86 @@ public async Task SaveOrUpdateQuestion(QuestionDTO questionDto) return questionModel.Id; } + public async Task SaveOrUpdateQuestion(IEnumerable questionDtos, int quizId) + { + var savedQuestionIds = new List(); + + var quizModel = db.Quizzes.SingleOrDefault(x => x.Id == quizId); + if (quizModel is null) + { + throw new InvalidOperationException($"Cannot find quiz with id = {quizId}"); + } + + foreach (var questionDto in questionDtos) + { + Question questionModel; + + if (questionDto.Id > 0) + { + // Update existing question + questionModel = db.Questions.Include(q => q.Answers).SingleOrDefault(q => q.Id == questionDto.Id); + if (questionModel is null) + { + throw new InvalidOperationException($"Cannot find question with id = {questionDto.Id}"); + } + + questionModel.Title = questionDto.Title; + + // Update or remove existing answers + var existingAnswers = questionModel.Answers.ToList(); + foreach (var existingAnswer in existingAnswers) + { + var answerDto = questionDto.Answers.SingleOrDefault(a => a.Id == existingAnswer.Id); + if (answerDto != null) + { + existingAnswer.Text = answerDto.Text; + existingAnswer.IsCorrect = answerDto.IsCorrect; + } + else + { + db.Answers.Remove(existingAnswer); + } + } + + // Add new answers + var newAnswers = questionDto.Answers.Where(a => a.Id == 0).ToList(); + foreach (var newAnswerDto in newAnswers) + { + var newAnswer = new Answer + { + Text = newAnswerDto.Text, + IsCorrect = newAnswerDto.IsCorrect, + Question = questionModel + }; + questionModel.Answers.Add(newAnswer); + } + } + else + { + // Create new question + questionModel = new Question + { + QuizId = quizModel.Id, + Quiz = quizModel, + Title = questionDto.Title, + Answers = questionDto.Answers.Select(a => new Answer + { + Text = a.Text, + IsCorrect = a.IsCorrect + }).ToList() + }; + + db.Questions.Add(questionModel); + } + + await db.SaveChangesAsync(); + + savedQuestionIds.Add(questionModel.Id); + } + + return savedQuestionIds.Count; + } + public async Task> GetQuestions() { return await db.Questions @@ -207,4 +287,29 @@ public async Task DeleteQuiz(int quizId) await db.SaveChangesAsync(); return true; } + + public async Task> GetQuestionsByQuizId(int quizId) + { + var questions = await db.Questions.Where(q => q.QuizId == quizId) + .Include(q => q.Answers) + .ToListAsync(); + + if(questions.Count == 0) + { + return Enumerable.Empty(); + } + + return questions.Select(q => new QuestionDTO + { + Id = q.Id, + QuizId = quizId, + Title = q.Title, + Answers = q.Answers.Select(a => new AnswerDTO + { + Id = a.Id, + Text = a.Text, + IsCorrect = a.IsCorrect + }).ToList() + }); + } } \ No newline at end of file diff --git a/QuizApp.Test/QuizRepositoryTest.cs b/QuizApp.Test/QuizRepositoryTest.cs index 01173b9..a881396 100644 --- a/QuizApp.Test/QuizRepositoryTest.cs +++ b/QuizApp.Test/QuizRepositoryTest.cs @@ -19,6 +19,56 @@ public QuizRepositoryTest() quizRepository = new QuizRepository(new QuizContext(Options)); } + [Fact] + public async Task GetQuestionsByQuizIdOK() + { + using (var setupContext = new QuizContext(Options)) + { + // Ensure the database is clean + await setupContext.Database.EnsureDeletedAsync(); + await setupContext.Database.EnsureCreatedAsync(); + + // Seed the required quiz + setupContext.Quizzes.Add(new Quiz + { + Id = 1, + QuizName = "General Knowledge", + Description = "GK", + }); + + setupContext.Questions.Add(new Question + { + QuizId = 1, + Title = "What is the capital of France?", + Answers = new List + { + new Answer { Text = "Paris", IsCorrect = true }, + new Answer { Text = "London", IsCorrect = false } + } + }); + + setupContext.Questions.Add(new Question + { + QuizId = 1, + Title = "What is the capital of Viet Nam?", + Answers = new List + { + new Answer { Text = "Hanoi", IsCorrect = true }, + new Answer { Text = "HCMC", IsCorrect = false } + } + }); + + await setupContext.SaveChangesAsync(); + } + + // Act + var result = await quizRepository.GetQuestionsByQuizId(1); + + // Assert + result.Should().NotBeEmpty(); + result.Count().Should().Be(2); + } + [Fact] public async Task DeleteQuizOK() { diff --git a/QuizApp/Endpoints/Question/GetQuestionsByQuizIdEndpoint.cs b/QuizApp/Endpoints/Question/GetQuestionsByQuizIdEndpoint.cs new file mode 100644 index 0000000..0eaea92 --- /dev/null +++ b/QuizApp/Endpoints/Question/GetQuestionsByQuizIdEndpoint.cs @@ -0,0 +1,32 @@ +using FastEndpoints; +using QuizApp.Common.DTO; +using QuizApp.Common.Request; +using QuizApp.Database.Repositories; + +namespace QuizApp.Endpoints.Question; + +public class GetQuestionsByQuizIdEndpoint : Endpoint> +{ + private readonly IQuizRepository quizRepository; + public GetQuestionsByQuizIdEndpoint(IQuizRepository quizRepository) + { + this.quizRepository = quizRepository; + } + + public override void Configure() + { + Get("question/getquestionbyquizid"); + AllowAnonymous(); + } + + public override async Task HandleAsync(GetQuestionByQuizIdRequest r, CancellationToken ct) + { + var result = await quizRepository.GetQuestionsByQuizId(r.QuizId); + if (result == null) + { + ThrowError("Cannot get all questions"); + } + + await SendAsync(result); + } +} diff --git a/QuizApp/Endpoints/Question/SaveOrUpdateQuestion.cs b/QuizApp/Endpoints/Question/SaveOrUpdateQuestion.cs new file mode 100644 index 0000000..4fed9f7 --- /dev/null +++ b/QuizApp/Endpoints/Question/SaveOrUpdateQuestion.cs @@ -0,0 +1,32 @@ +using FastEndpoints; +using QuizApp.Common.Request; +using QuizApp.Database.Repositories; + +namespace QuizApp.Endpoints.Question +{ + public class SaveOrUpdateQuestion : Endpoint + { + private readonly IQuizRepository quizRepository; + public SaveOrUpdateQuestion(IQuizRepository quizRepository) + { + this.quizRepository = quizRepository; + } + + public override void Configure() + { + Post("question/savequestions"); + AllowAnonymous(); + } + + public override async Task HandleAsync(SaveOrUpdateQuestionRequest r, CancellationToken ct) + { + var result = await quizRepository.SaveOrUpdateQuestion(r.QuestionDTOs, r.QuizId); + if (result < 0) + { + ThrowError("Cannot save all questions"); + } + + await SendAsync(result); + } + } +}