Skip to content

Commit

Permalink
Profile history pages (CS3219-AY2425S1#80)
Browse files Browse the repository at this point in the history
* Create Profile Page

Profile page is under '/account' route. It allows users to change
account details and password.

I moved around some files and refactored getters from register.component into
forms.utils.service.

Slightly changed behavior of user service update user. Did not
understand what it was trying to do before, so I changed it to work. It
feels like it works, but not extensively tested.

Known bugs: (1) Enter does not submit edit forms. (2) Edit profile form does not
inherit theme from primeng.

* Add webpage for history

Branch has not merged with history service, so although history service
is made, it is not being used.

* Working History page

Bugs fixed: Imported PInputText so editing profile page looks normal. The behaviour for 'Enter' to submit form is restored for profile page.

Not Fixed: Linting.

* Fix linting

Added interfaces for hisotry response.

* Fix merge

* Fix suggestions on the PR

Most of it has been fixed, except for changing how user service works.

* Improve error messages

* Enable viewing of code in history page

Similar to questions page, there is a sliding window to view the last
snapshot of the coding session before forfeit or submit.

History table is now searchable.

Profile page now properly subscribes to user$ and does not need to
refresh.

Known bugs: during a colab session, cannot view history page. No idea
how to debug since it did not show errors or any console.log()

* Fix linting

* Fix test case

* Fix bug where histories cannot be loaded during colab session

Fix interfaces to be more correct.

* Fix lint

* Add new routes to user service to handle update to user details

* Redefine zod schemas for better reusability
* Add route to handle update to user's username and email
* Add route to handle update to user's password

* Update frontend to call the correct routes when updating user profile

* Fix linting

* Add dialogs for editing user profile and password

* Update styles for profile page

* Shift buttons out of profile container
* Display profile details as text instead of readonly input

* Refactor backend to handle history snapshot

Remove the need for the frontend to send the final code and language

* Fix history panel appearing when no code history exists

* Add description to question

* Fix history code being editable

* Remove redundant login calls

* Redirect to the collab session if in progress

---------

Co-authored-by: Samuel Lim <[email protected]>
Co-authored-by: McNaBry <[email protected]>
  • Loading branch information
3 people authored Nov 13, 2024
1 parent 3ccae10 commit 3164014
Show file tree
Hide file tree
Showing 45 changed files with 2,628 additions and 1,716 deletions.
4 changes: 0 additions & 4 deletions compose.dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,6 @@ services:
- /app/node_modules
- ./services/collaboration:/app

collaboration-db:
ports:
- 27020:27017

history:
command: npm run dev
ports:
Expand Down
20 changes: 20 additions & 0 deletions frontend/src/_services/authentication.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,26 @@ export class AuthenticationService extends ApiService {
.pipe(switchMap(() => this.login(username, password))); // auto login after registration
}

updateUsernameAndEmail(username: string, email: string, password: string) {
return this.http
.patch<UServRes>(
`${this.apiUrl}/users/username-email/${this.userValue!.id}`,
{ username: username, email: email, password: password },
{ observe: 'response' },
)
.pipe(switchMap(() => this.login(username, password))); // login to update local storage and subject
}

updatePassword(username: string, oldPassword: string, newPassword: string) {
return this.http
.patch<UServRes>(
`${this.apiUrl}/users/password/${this.userValue!.id}`,
{ oldPassword: oldPassword, newPassword: newPassword },
{ observe: 'response' },
)
.pipe(switchMap(() => this.login(username, newPassword))); // login to update local storage and subject
}

logout() {
// remove user from local storage to log user out
localStorage.removeItem('user');
Expand Down
87 changes: 87 additions & 0 deletions frontend/src/_services/form.utils.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { Injectable } from '@angular/core';
import { AbstractControl, FormGroup } from '@angular/forms';
import { PASSWORD_LOWERCASE } from '../app/account/_validators/lowercase-password';
import { PASSWORD_UPPERCASE } from '../app/account/_validators/uppercase-password';
import { PASSWORD_NUMERIC } from '../app/account/_validators/numeric-password';
import { PASSWORD_SPECIAL } from '../app/account/_validators/special-password';
import { PASSWORD_SHORT } from '../app/account/_validators/short-password';
import { PASSWORD_WEAK } from '../app/account/_validators/weak-password.validator';
import { PASSWORD_MISMATCH } from '../app/account/_validators/mismatch-password.validator';
import { USERNAME_INVALID } from '../app/account/_validators/invalid-username.validator';
import { PASSWORD_INVALID } from '../app/account/_validators/invalid-password.validator';

@Injectable({
providedIn: 'root',
})

// This service is used to validate the form fields in the register and profile components
export class FormUtilsService {
get isUsernameInvalid(): (form: FormGroup) => boolean {
return (form: FormGroup) => {
const usernameControl = form.controls['username'];
return usernameControl.dirty && usernameControl.hasError(USERNAME_INVALID);
};
}

get isEmailInvalid(): (form: FormGroup) => boolean {
return (form: FormGroup) => {
const emailControl = form.controls['email'];
return emailControl.dirty && emailControl.invalid;
};
}

get passwordControl(): (form: FormGroup) => AbstractControl {
return (form: FormGroup) => form.controls['password'];
}

get isPasswordControlDirty(): (form: FormGroup) => boolean {
return (form: FormGroup) => this.passwordControl(form).dirty;
}

get passwordHasNoLowercase(): (form: FormGroup) => boolean {
return (form: FormGroup) =>
this.passwordControl(form).pristine || this.passwordControl(form).hasError(PASSWORD_LOWERCASE);
}

get passwordHasNoUppercase(): (form: FormGroup) => boolean {
return (form: FormGroup) =>
this.passwordControl(form).pristine || this.passwordControl(form).hasError(PASSWORD_UPPERCASE);
}

get passwordHasNoNumeric(): (form: FormGroup) => boolean {
return (form: FormGroup) =>
this.passwordControl(form).pristine || this.passwordControl(form).hasError(PASSWORD_NUMERIC);
}

get passwordHasNoSpecial(): (form: FormGroup) => boolean {
return (form: FormGroup) =>
this.passwordControl(form).pristine || this.passwordControl(form).hasError(PASSWORD_SPECIAL);
}

get isPasswordShort(): (form: FormGroup) => boolean {
return (form: FormGroup) =>
this.passwordControl(form).pristine || this.passwordControl(form).hasError(PASSWORD_SHORT);
}

get isPasswordWeak(): (form: FormGroup) => boolean {
return (form: FormGroup) =>
this.passwordControl(form).dirty && this.passwordControl(form).hasError(PASSWORD_WEAK);
}

get isPasswordStrong(): (form: FormGroup) => boolean {
return (form: FormGroup) =>
this.passwordControl(form).dirty && !this.passwordControl(form).hasError(PASSWORD_WEAK);
}

get isPasswordInvalid(): (form: FormGroup) => boolean {
return (form: FormGroup) =>
this.passwordControl(form).dirty && this.passwordControl(form).hasError(PASSWORD_INVALID);
}

get hasPasswordMismatch(): (form: FormGroup) => boolean {
return (form: FormGroup) => {
const confirmPasswordControl = form.controls['confirmPassword'];
return this.passwordControl(form).valid && confirmPasswordControl.dirty && form.hasError(PASSWORD_MISMATCH);
};
}
}
36 changes: 36 additions & 0 deletions frontend/src/_services/history.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { historyResponse, MatchingHistory } from '../app/account/history/history.model';
import { ApiService } from './api.service';

@Injectable({
providedIn: 'root',
})
export class HistoryService extends ApiService {
protected apiPath = 'history/history';

constructor(private http: HttpClient) {
super();
}

getHistories(): Observable<MatchingHistory[]> {
return this.http.get<historyResponse>(`${this.apiUrl}`).pipe(
map(response =>
response.data.map(item => ({
id: item._id,
roomId: item.roomId,
collaborator: item.collaborator.username,
question: item.question,
topics: item.question.topics,
difficulty: item.question.difficulty,
status: item.status,
time: item.createdAt,
language: item.snapshot?.language,
code: item.snapshot?.code,
})),
),
);
}
}
8 changes: 6 additions & 2 deletions frontend/src/app/account/account.component.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';

import { LoginComponent } from './login.component';
import { RegisterComponent } from './register.component';
import { LoginComponent } from './login/login.component';
import { RegisterComponent } from './register/register.component';
import { LayoutComponent } from './layout.component';
import { ProfileComponent } from './profile/profile.component';
import { HistoryComponent } from './history/history.component';

const routes: Routes = [
{
Expand All @@ -13,6 +15,8 @@ const routes: Routes = [
{ path: '', redirectTo: 'login', pathMatch: 'full' },
{ path: 'login', component: LoginComponent },
{ path: 'register', component: RegisterComponent },
{ path: 'profile', component: ProfileComponent },
{ path: 'history', component: HistoryComponent },
],
},
];
Expand Down
8 changes: 6 additions & 2 deletions frontend/src/app/account/account.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import { NgModule } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';
import { CommonModule } from '@angular/common';

import { LoginComponent } from './login.component';
import { RegisterComponent } from './register.component';
import { LoginComponent } from './login/login.component';
import { RegisterComponent } from './register/register.component';
import { LayoutComponent } from './layout.component';
import { AccountRoutingModule } from './account.component';
import { ProfileComponent } from './profile/profile.component';
import { HistoryComponent } from './history/history.component';

@NgModule({
imports: [
Expand All @@ -15,6 +17,8 @@ import { AccountRoutingModule } from './account.component';
LayoutComponent,
LoginComponent,
RegisterComponent,
ProfileComponent,
HistoryComponent,
],
})
export class AccountModule {}
39 changes: 39 additions & 0 deletions frontend/src/app/account/history/history.component.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
.sliding-panel {
position: fixed;
top: 0;
right: -600px; /* Adjust the width as needed */
width: 600px;
height: 100%;
background-color: #181818 !important;
color: var(--text-color); /* Use theme variable */
box-shadow: -2px 0 5px rgba(0,0,0,0.5);
transition: right 0.3s ease;
z-index: 1000;
}

.sliding-panel.open {
right: 0;
}

.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background-color: #181818 !important;
border-bottom: 1px solid #000000; /* Use theme variable */
}

.panel-content {
padding: 1rem;
line-height: 1.6; /* Adjust line height for better readability */
color: #ffffff; /* Ensure text color is readable */
}

.panel-content p {
margin-bottom: 1rem; /* Add margin to paragraphs for spacing */
}

tr:hover {
background-color: rgba(0, 0, 0, 0.1);
}
84 changes: 84 additions & 0 deletions frontend/src/app/account/history/history.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<div class="table-container">
<p-table
#dt
sortField="time"
[sortOrder]="1"
[value]="histories"
datakey="id"
[tableStyle]="{ 'table-layout': 'auto', width: '100%', 'text-align': 'center' }"
[paginator]="true"
[rows]="10"
[rowsPerPageOptions]="[10, 25, 50]"
[globalFilterFields]="['question', 'difficulty', 'topics', 'collaborator', 'status', 'time']"
styleClass="p-datatable-gridlines-striped">
<ng-template pTemplate="caption">
<div class="flex">
<h3 class="m-0">Matching History</h3>
</div>
</ng-template>
<ng-template pTemplate="caption">
<div class="flex">
<p-iconField iconPosition="left" class="ml-auto">
<p-inputIcon>
<i class="pi pi-search"></i>
</p-inputIcon>
<input
pInputText
type="text"
(input)="dt.filterGlobal($any($event.target).value, 'contains')"
placeholder="Search keyword" />
</p-iconField>
</div>
</ng-template>
<ng-template pTemplate="header" let-columns>
<tr>
<th pSortableColumn="question" style="width: 20%">
Question<p-sortIcon field="question"></p-sortIcon>
</th>
<th pSortableColumn="difficulty" style="width: 14%">
Difficulty<p-sortIcon field="difficulty"></p-sortIcon>
</th>
<th pSortableColumn="topics" style="width: 25%">Topics<p-sortIcon field="topics"></p-sortIcon></th>
<th pSortableColumn="collaborator" style="width: 17%">
Collaborator<p-sortIcon field="collaborator"></p-sortIcon>
</th>
<th pSortableColumn="status" style="width: 12%">Status<p-sortIcon field="status"></p-sortIcon></th>
<th pSortableColumn="time" style="width: 12%">Time<p-sortIcon field="time"></p-sortIcon></th>
</tr>
</ng-template>
<ng-template pTemplate="body" let-history>
<tr (click)="onRowSelect(history)">
<td>{{ history.question.title }}</td>
<td>{{ history.difficulty }}</td>
<td>{{ history.topics.join(', ') }}</td>
<td>{{ history.collaborator }}</td>
<td>
@if (history.status === 'COMPLETED') {
<i class="pi pi-check" style="color: green; font-size: large"></i>
} @else if (history.status === 'FORFEITED') {
<i class="pi pi-times" style="color: red; font-size: large"></i>
} @else if (history.status === 'IN_PROGRESS') {
<i class="pi pi-spin pi-spinner" style="color: white; font-size: large"></i>
}
</td>
<td>{{ history.time }}</td>
</tr>
</ng-template>
</p-table>
</div>
<div class="sliding-panel" [class.open]="isPanelVisible">
<div class="panel-header">
<h4>{{ panelHistory?.question?.title }}</h4>
<p-button
icon="pi pi-times"
severity="secondary"
label="Close"
(onClick)="closePanel()"
class="p-button-text" />
</div>
<div class="panel-content">
<p>{{ panelHistory?.question?.description }}</p>
<div #editor class="editor-content"></div>
</div>
</div>
<p-toast position="bottom-right" [breakpoints]="{ '920px': { width: '90%' } }" />
Loading

0 comments on commit 3164014

Please sign in to comment.