Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add question upload component #50

Closed
wants to merge 8 commits into from
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions frontend/src/_services/question.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
SingleQuestionResponse,
QuestionResponse,
QuestionBody,
UploadQuestionsResponse,
MessageOnlyResponse,
} from '../app/questions/question.model';
import { TopicResponse } from '../app/questions/topic.model';
Expand Down Expand Up @@ -74,6 +75,12 @@ export class QuestionService {
.pipe(catchError(this.handleError));
}

uploadQuestions(file: File): Observable<UploadQuestionsResponse> {
const formData = new FormData();
formData.append('file', file);
return this.http.post<UploadQuestionsResponse>(this.baseUrl + '/questions/upload', formData);
}

updateQuestion(id: number, question: QuestionBody): Observable<SingleQuestionResponse> {
return this.http
.put<SingleQuestionResponse>(this.baseUrl + '/' + id, question, this.httpOptions)
Expand Down
Empty file.
35 changes: 35 additions & 0 deletions frontend/src/app/questions/question-upload.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<p-dialog header="Upload questions" [(visible)]="isVisible" [style]="{ width: '40rem' }" draggable="false">
<p-fileUpload
#fileUpload
name="file[]"
accept=".json"
maxFileSize="262144"
(onSelect)="onSelect($event, fileUpload)"
(uploadHandler)="upload($event, fileUpload)"
customUpload="true">
<ng-template pTemplate="header" let-files let-uploadCallback="uploadCallback">
@if (files?.length) {
<p-button (onClick)="uploadCallback()" label="Upload" severity="primary"></p-button>
}
</ng-template>
<ng-template
pTemplate="content"
let-files
let-uploadedFiles
let-removeFileCallback
let-uploadCallback="uploadCallback">
</ng-template>

<ng-template pTemplate="empty">
<div
class="flex align-items-center justify-content-center flex-column"
style="cursor: pointer"
tabindex="0"
(click)="fileUpload.choose()"
(keyup.enter)="fileUpload.choose()">
<i class="pi pi-cloud-upload border-2 border-circle p-5 text-8xl text-blue-400 border-400"></i>
<p class="mt-4 mb-0">Drag and drop files here to upload.</p>
</div>
</ng-template>
</p-fileUpload>
</p-dialog>
22 changes: 22 additions & 0 deletions frontend/src/app/questions/question-upload.component.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';

import { QuestionUploadComponent } from './question-upload.component';

describe('QuestionUploadComponent', () => {
let component: QuestionUploadComponent;
let fixture: ComponentFixture<QuestionUploadComponent>;

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [QuestionUploadComponent],
}).compileComponents();

fixture = TestBed.createComponent(QuestionUploadComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

it('should create', () => {
expect(component).toBeTruthy();
});
});
99 changes: 99 additions & 0 deletions frontend/src/app/questions/question-upload.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { Component, EventEmitter, Output } from '@angular/core';
import { MessageService } from 'primeng/api';
import { DialogModule } from 'primeng/dialog';
import { FileSelectEvent, FileUpload, FileUploadHandlerEvent, FileUploadModule } from 'primeng/fileupload';
import { Question, UploadQuestionsResponse } from './question.model';
import { QuestionService } from '../../_services/question.service';
import { HttpErrorResponse } from '@angular/common/http';
import { finalize } from 'rxjs';

@Component({
selector: 'app-question-upload',
standalone: true,
imports: [DialogModule, FileUploadModule],
templateUrl: './question-upload.component.html',
styleUrl: './question-upload.component.css',
})
export class QuestionUploadComponent {
@Output() questionsUpsert = new EventEmitter<Question[]>();
public isVisible = false;

constructor(
private messageService: MessageService,
private questionService: QuestionService,
) {}

private isValidJson(jsonString: string): boolean {
try {
JSON.parse(jsonString);
} catch {
return false;
}
return true;
}

onSelect(event: FileSelectEvent, fileUpload: FileUpload) {
const files = event.files || [];
for (const file of files) {
const reader = new FileReader();
reader.onloadend = () => {
const result = reader.result as string;

if (!this.isValidJson(result)) {
const idx = fileUpload.files.findIndex(f => f === file);
fileUpload.remove(event.originalEvent, idx);
fileUpload.cd.markForCheck();
this.messageService.add({
severity: 'error',
summary: 'Error',
detail: `The file ${file.name} is invalid!`,
life: 3000,
});
}
};
reader.readAsText(file);
}
}

upload(event: FileUploadHandlerEvent, fileUpload: FileUpload) {
const file = event.files[0];
fileUpload.uploading = true;

this.questionService
.uploadQuestions(file)
.pipe(finalize(() => this.onUploadFinish(fileUpload)))
.subscribe({
next: (response: UploadQuestionsResponse) => {
this.messageService.add({
severity: 'success',
summary: 'Success',
detail: 'Questions added!',
life: 3000,
});
this.questionsUpsert.emit(response.data);
this.onUploadFinish(fileUpload);
},
error: (error: HttpErrorResponse) => {
this.messageService.add({
severity: 'error',
summary: 'Error',
detail: `${error.error.message}`,
life: 3000,
});
const data = error.error?.data || [];
this.questionsUpsert.emit(data);
this.onUploadFinish(fileUpload);
},
});
}

onUploadFinish(fileUpload: FileUpload) {
fileUpload.uploading = false;
fileUpload.progress = 100;
fileUpload.cd.markForCheck();
setTimeout(() => {
this.isVisible = false;
fileUpload.clear();
}, 2000);
}
}
6 changes: 6 additions & 0 deletions frontend/src/app/questions/question.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ export interface SingleQuestionResponse extends BaseResponse {
data: Question;
}

export interface UploadQuestionsResponse {
status: string;
message: string;
data: Question[];
}

export interface Question {
id: number;
description: string;
Expand Down
9 changes: 8 additions & 1 deletion frontend/src/app/questions/questions.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,14 @@
icon="pi pi-trash"
severity="danger"
label="Delete"
class="mr-2"
(onClick)="deleteSelectedQuestions()"
[disabled]="!selectedQuestions || !selectedQuestions.length" />
[disabled]="!selectedQuestions?.length" />
<p-button
icon="pi pi-file-arrow-up"
severity="success"
label="Upload"
(onClick)="uploadDialog.isVisible = true" />
</div>
</p-toolbar>
<p-table
Expand Down Expand Up @@ -84,5 +90,6 @@ <h3 class="m-0">Manage Questions</h3>
(errorReceive)="onErrorReceive($event)"
(successfulRequest)="onSuccessfulRequest($event)">
</app-question-dialog>
<app-question-upload #uploadDialog (questionsUpsert)="onQuestionsUpsert($event)"></app-question-upload>
<p-confirmDialog [style]="{ width: '450px' }" />
</div>
12 changes: 12 additions & 0 deletions frontend/src/app/questions/questions.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { QuestionService } from '../../_services/question.service';
import { HttpErrorResponse } from '@angular/common/http';
import { QuestionDialogComponent } from './question-dialog.component';
import { Column } from './column.model';
import { QuestionUploadComponent } from './question-upload.component';

@Component({
selector: 'app-questions',
Expand All @@ -37,6 +38,7 @@ import { Column } from './column.model';
DropdownModule,
ProgressSpinnerModule,
QuestionDialogComponent,
QuestionUploadComponent,
],
providers: [QuestionService, ConfirmationService, MessageService],
templateUrl: './questions.component.html',
Expand Down Expand Up @@ -129,6 +131,16 @@ export class QuestionsComponent implements OnInit {
this.isDialogVisible = false;
}

onQuestionsUpsert(questions: Question[]) {
const questionIds = this.questions.map(q => q.id);

const updated = questions.filter(q => questionIds.includes(q.id));
const inserted = questions.filter(q => !questionIds.includes(q.id));

updated.forEach(q => (this.questions[this.questions.findIndex(x => x.id == q.id)] = q));
this.questions = this.questions.concat(inserted);
}

onQuestionUpdate(question: Question) {
this.questions[this.questions.findIndex(x => x.id == question.id)] = question;
this.questions = [...this.questions];
Expand Down
39 changes: 39 additions & 0 deletions services/question/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,45 @@ curl -X POST http://localhost:8081/questions -H "Content-Type: application/json"

---

## Upload Questions

This endpoint allows the uploading of new questions through uploading a `.json` file.

- **HTTP Method**: `POST`
- **Endpoint**: `/questions/upload`

### Request Body:

The request body must contain a `file` field containing a single JSON file uploaded as part of a `multipart/form-data` request. The JSON file should contain an array of question objects with the following fields:

- `id` (Required) - The identifier of the question.
- `title` (Required) - The title of the question.
- `description` (Required) - A description of the question.
- `topics` (Required) - The topics associated with the question.
- `difficulty` (Required) - The difficulty level of the question.

### Responses:

| Response Code | Explanation |
|-----------------------------|------------------------------------------------------------------------------------------------|
| 201 (Created) | The questions were created successfully. |
| 400 (Bad Request) | No file was uploaded or the file was invalid. |
| 409 (Conflict) | Some questions were successfully created, but conflicts were detected with existing questions. |
| 500 (Internal Server Error) | Unexpected error in the database or server. |


### Example of Response Body for Success:

```json
{
"status": "Success",
"message": "Questions created successfully",
"data": null
}
```

---

## Update Question

This endpoint allows updating an existing question. Only the title, description, topics, and difficulty can be updated.
Expand Down
Loading