Skip to content

Commit

Permalink
Merge pull request #30 from CS3219-AY2425S1/question-spa-frontend
Browse files Browse the repository at this point in the history
Implement Question SPA
  • Loading branch information
limcaaarl authored Sep 28, 2024
2 parents 363d91d + 28dbdd2 commit 0db5a22
Show file tree
Hide file tree
Showing 23 changed files with 851 additions and 6 deletions.
4 changes: 2 additions & 2 deletions frontend/angular.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
"maximumWarning": "2MB",
"maximumError": "3MB"
},
{
"type": "anyComponentStyle",
Expand Down
7 changes: 7 additions & 0 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"@angular/platform-browser-dynamic": "^18.2.0",
"@angular/router": "^18.2.0",
"primeflex": "^3.3.1",
"primeicons": "^7.0.0",
"primeng": "^17.18.10",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
Expand Down
16 changes: 16 additions & 0 deletions frontend/src/_services/question.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';

import { QuestionService } from './question.service';

describe('QuestionService', () => {
let service: QuestionService;

beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(QuestionService);
});

it('should be created', () => {
expect(service).toBeTruthy();
});
});
87 changes: 87 additions & 0 deletions frontend/src/_services/question.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { HttpClient, HttpErrorResponse, HttpHeaders, HttpParams } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { API_CONFIG } from '../app/api.config';
import { catchError, Observable, throwError } from 'rxjs';
import { SingleQuestionResponse, QuestionResponse, QuestionBody } from '../app/questions/question.model';
import { TopicResponse } from '../app/questions/topic.model';

@Injectable({
providedIn: 'root',
})
export class QuestionService {
private baseUrl = API_CONFIG.baseUrl;

private httpOptions = {
headers: new HttpHeaders({
'Content-Type': 'application/json',
}),
};

constructor(private http: HttpClient) {}

getQuestions(
title?: string,
description?: string,
topics?: string[],
difficulty?: string,
): Observable<QuestionResponse> {
let params = new HttpParams();

if (title) {
params = params.append('title', title);
}
if (description) {
params = params.append('description', description);
}
if (topics && topics.length > 0) {
params = params.append('topics', topics.join(','));
}
if (difficulty) {
params = params.append('difficulty', difficulty);
}

// send request
return this.http.get<QuestionResponse>(this.baseUrl + '/questions', { params });
}

getQuestionByID(id: number): Observable<QuestionResponse> {
return this.http.get<QuestionResponse>(this.baseUrl + '/questions/' + id);
}

getQuestionByParam(topics: string[], difficulty: string, limit?: number): Observable<QuestionResponse> {
let params = new HttpParams();

if (limit) {
params = params.append('limit', limit);
}
params = params.append('topics', topics.join(',')).append('difficulty', difficulty);

return this.http.get<QuestionResponse>(this.baseUrl + '/questions/search', { params });
}

getTopics(): Observable<TopicResponse> {
return this.http.get<TopicResponse>(this.baseUrl + '/questions/topics');
}

addQuestion(question: QuestionBody): Observable<SingleQuestionResponse> {
return this.http
.post<SingleQuestionResponse>(this.baseUrl + '/questions', question, this.httpOptions)
.pipe(catchError(this.handleError));
}

updateQuestion(id: number, question: QuestionBody): Observable<SingleQuestionResponse> {
return this.http
.put<SingleQuestionResponse>(this.baseUrl + '/questions/' + id, question, this.httpOptions)
.pipe(catchError(this.handleError));
}

deleteQuestion(id: number): Observable<SingleQuestionResponse> {
return this.http
.delete<SingleQuestionResponse>(this.baseUrl + '/questions/' + id)
.pipe(catchError(this.handleError));
}

handleError(error: HttpErrorResponse) {
return throwError(error);
}
}
3 changes: 3 additions & 0 deletions frontend/src/app/api.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const API_CONFIG = {
baseUrl: 'http://localhost:8081',
};
9 changes: 7 additions & 2 deletions frontend/src/app/app.config.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';

import { routes } from './app.routes';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { provideHttpClient } from '@angular/common/http';

export const appConfig: ApplicationConfig = {
providers: [provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes), provideAnimationsAsync()],
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes),
provideAnimationsAsync(),
provideHttpClient(),
],
};
5 changes: 5 additions & 0 deletions frontend/src/app/app.routes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Routes } from '@angular/router';
import { QuestionsComponent } from './questions/questions.component';

const accountModule = () => import('./account/account.module').then(x => x.AccountModule);

Expand All @@ -7,4 +8,8 @@ export const routes: Routes = [
path: 'account',
loadChildren: accountModule,
},
{
path: 'questions',
component: QuestionsComponent,
},
];
4 changes: 4 additions & 0 deletions frontend/src/app/questions/column.model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export interface Column {
field: string;
header: string;
}
5 changes: 5 additions & 0 deletions frontend/src/app/questions/difficulty-levels.enum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export enum DifficultyLevels {
EASY = 'Easy',
MEDIUM = 'Medium',
HARD = 'Hard',
}
4 changes: 4 additions & 0 deletions frontend/src/app/questions/difficulty.model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export interface Difficulty {
label: string;
value: string;
}
22 changes: 22 additions & 0 deletions frontend/src/app/questions/question-dialog.component.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
.container {
padding: 2rem;
background-color: var(--surface-section);
border-radius: 0.75rem;
display: flex;
flex-direction: column;
align-items: center;
}

.form-container {
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
}

.form-field {
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 100%;
}
102 changes: 102 additions & 0 deletions frontend/src/app/questions/question-dialog.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<p-dialog
header="Header"
(onHide)="cancel()"
(onShow)="show()"
[(visible)]="isDialogVisible"
[modal]="true"
[style]="{ width: '25rem' }">
<ng-template pTemplate="header">
<div class="inline-flex align-items-center justify-content-center gap-2">
<span class="font-bold white-space-nowrap"> {{ headerMessage }}</span>
</div>
</ng-template>

<form [formGroup]="questionFormGroup">
<div class="form-field mb-4">
<label for="title">Title</label>
<input
formControlName="title"
type="text"
pInputText
id="title"
required
class="text-base text-color surface-overlay p-2 border-1 border-solid surface-border border-round appearance-none outline-none focus:border-primary w-full" />
@if (isTitleInvalid) {
<small class="text-red-300">Title is required.</small>
}
</div>
<div class="form-field mb-4">
<label for="questionTopics">Topics</label>

<p-multiSelect
#topicSelector
required="true"
class="w-12"
[style]="{ width: '100%' }"
[options]="topics"
(onFilter)="onFilterTopics($event)"
formControlName="topics"
optionLabel="label"
optionValue="value"
placeholder="Select Topics">
<ng-template class="w-12 p-fluid" let-option pTemplate="item">
<span>{{ option.label }}</span>
</ng-template>

<ng-template pTemplate="footer" class="w-12 p-fluid">
@if (hasNoResultsFound) {
<p-button
class="w-12 p-fluid"
type="button"
label="Add as New Topic"
icon="pi pi-plus"
(click)="addNewTopic()" />
}
</ng-template>
</p-multiSelect>
@if (isTopicsInvalid) {
<small class="text-red-300">Topic(s) is required.</small>
}
</div>
<div class="form-field mb-4">
<label for="questionTopics">Difficulty</label>

<p-dropdown
class="w-12"
autoWidth="false"
[required]="true"
[style]="{ width: '100%' }"
[options]="difficulties"
formControlName="difficulty"
optionLabel="label"
optionValue="value"
placeholder="Select Difficulty" />
@if (isDifficultyInvalid) {
<small class="text-red-300">Difficulty is required.</small>
}
</div>
<div class="form-field">
<label for="questionDescription">Description</label>
<textarea
formControlName="description"
[required]="true"
id="questionDescription"
type="text"
rows="6"
class="text-base text-color surface-overlay p-2 border-1 border-solid surface-border border-round appearance-none outline-none focus:border-primary w-full">
</textarea>
@if (isDescriptionInvalid) {
<small class="text-red-300">Description is required.</small>
}
</div>
</form>

<ng-template pTemplate="footer">
<p-button label="Cancel" [text]="true" severity="secondary" (onClick)="dialogClose.emit()" />
<p-button
label="Save"
class="p-button-success"
(onClick)="saveQuestion()"
[disabled]="!questionFormGroup.valid" />
</ng-template>
</p-dialog>
22 changes: 22 additions & 0 deletions frontend/src/app/questions/question-dialog.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 { QuestionDialogComponent } from './question-dialog.component';

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

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

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

it('should create', () => {
expect(component).toBeTruthy();
});
});
Loading

0 comments on commit 0db5a22

Please sign in to comment.