Skip to content

Commit 3164014

Browse files
LimZiJiasamuelim01McNaBry
authored
Profile history pages (#80)
* 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]>
1 parent 3ccae10 commit 3164014

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

45 files changed

+2628
-1716
lines changed

compose.dev.yml

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,6 @@ services:
4848
- /app/node_modules
4949
- ./services/collaboration:/app
5050

51-
collaboration-db:
52-
ports:
53-
- 27020:27017
54-
5551
history:
5652
command: npm run dev
5753
ports:

frontend/src/_services/authentication.service.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,26 @@ export class AuthenticationService extends ApiService {
6868
.pipe(switchMap(() => this.login(username, password))); // auto login after registration
6969
}
7070

71+
updateUsernameAndEmail(username: string, email: string, password: string) {
72+
return this.http
73+
.patch<UServRes>(
74+
`${this.apiUrl}/users/username-email/${this.userValue!.id}`,
75+
{ username: username, email: email, password: password },
76+
{ observe: 'response' },
77+
)
78+
.pipe(switchMap(() => this.login(username, password))); // login to update local storage and subject
79+
}
80+
81+
updatePassword(username: string, oldPassword: string, newPassword: string) {
82+
return this.http
83+
.patch<UServRes>(
84+
`${this.apiUrl}/users/password/${this.userValue!.id}`,
85+
{ oldPassword: oldPassword, newPassword: newPassword },
86+
{ observe: 'response' },
87+
)
88+
.pipe(switchMap(() => this.login(username, newPassword))); // login to update local storage and subject
89+
}
90+
7191
logout() {
7292
// remove user from local storage to log user out
7393
localStorage.removeItem('user');
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { Injectable } from '@angular/core';
2+
import { AbstractControl, FormGroup } from '@angular/forms';
3+
import { PASSWORD_LOWERCASE } from '../app/account/_validators/lowercase-password';
4+
import { PASSWORD_UPPERCASE } from '../app/account/_validators/uppercase-password';
5+
import { PASSWORD_NUMERIC } from '../app/account/_validators/numeric-password';
6+
import { PASSWORD_SPECIAL } from '../app/account/_validators/special-password';
7+
import { PASSWORD_SHORT } from '../app/account/_validators/short-password';
8+
import { PASSWORD_WEAK } from '../app/account/_validators/weak-password.validator';
9+
import { PASSWORD_MISMATCH } from '../app/account/_validators/mismatch-password.validator';
10+
import { USERNAME_INVALID } from '../app/account/_validators/invalid-username.validator';
11+
import { PASSWORD_INVALID } from '../app/account/_validators/invalid-password.validator';
12+
13+
@Injectable({
14+
providedIn: 'root',
15+
})
16+
17+
// This service is used to validate the form fields in the register and profile components
18+
export class FormUtilsService {
19+
get isUsernameInvalid(): (form: FormGroup) => boolean {
20+
return (form: FormGroup) => {
21+
const usernameControl = form.controls['username'];
22+
return usernameControl.dirty && usernameControl.hasError(USERNAME_INVALID);
23+
};
24+
}
25+
26+
get isEmailInvalid(): (form: FormGroup) => boolean {
27+
return (form: FormGroup) => {
28+
const emailControl = form.controls['email'];
29+
return emailControl.dirty && emailControl.invalid;
30+
};
31+
}
32+
33+
get passwordControl(): (form: FormGroup) => AbstractControl {
34+
return (form: FormGroup) => form.controls['password'];
35+
}
36+
37+
get isPasswordControlDirty(): (form: FormGroup) => boolean {
38+
return (form: FormGroup) => this.passwordControl(form).dirty;
39+
}
40+
41+
get passwordHasNoLowercase(): (form: FormGroup) => boolean {
42+
return (form: FormGroup) =>
43+
this.passwordControl(form).pristine || this.passwordControl(form).hasError(PASSWORD_LOWERCASE);
44+
}
45+
46+
get passwordHasNoUppercase(): (form: FormGroup) => boolean {
47+
return (form: FormGroup) =>
48+
this.passwordControl(form).pristine || this.passwordControl(form).hasError(PASSWORD_UPPERCASE);
49+
}
50+
51+
get passwordHasNoNumeric(): (form: FormGroup) => boolean {
52+
return (form: FormGroup) =>
53+
this.passwordControl(form).pristine || this.passwordControl(form).hasError(PASSWORD_NUMERIC);
54+
}
55+
56+
get passwordHasNoSpecial(): (form: FormGroup) => boolean {
57+
return (form: FormGroup) =>
58+
this.passwordControl(form).pristine || this.passwordControl(form).hasError(PASSWORD_SPECIAL);
59+
}
60+
61+
get isPasswordShort(): (form: FormGroup) => boolean {
62+
return (form: FormGroup) =>
63+
this.passwordControl(form).pristine || this.passwordControl(form).hasError(PASSWORD_SHORT);
64+
}
65+
66+
get isPasswordWeak(): (form: FormGroup) => boolean {
67+
return (form: FormGroup) =>
68+
this.passwordControl(form).dirty && this.passwordControl(form).hasError(PASSWORD_WEAK);
69+
}
70+
71+
get isPasswordStrong(): (form: FormGroup) => boolean {
72+
return (form: FormGroup) =>
73+
this.passwordControl(form).dirty && !this.passwordControl(form).hasError(PASSWORD_WEAK);
74+
}
75+
76+
get isPasswordInvalid(): (form: FormGroup) => boolean {
77+
return (form: FormGroup) =>
78+
this.passwordControl(form).dirty && this.passwordControl(form).hasError(PASSWORD_INVALID);
79+
}
80+
81+
get hasPasswordMismatch(): (form: FormGroup) => boolean {
82+
return (form: FormGroup) => {
83+
const confirmPasswordControl = form.controls['confirmPassword'];
84+
return this.passwordControl(form).valid && confirmPasswordControl.dirty && form.hasError(PASSWORD_MISMATCH);
85+
};
86+
}
87+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { Injectable } from '@angular/core';
2+
import { HttpClient } from '@angular/common/http';
3+
import { Observable } from 'rxjs';
4+
import { map } from 'rxjs/operators';
5+
import { historyResponse, MatchingHistory } from '../app/account/history/history.model';
6+
import { ApiService } from './api.service';
7+
8+
@Injectable({
9+
providedIn: 'root',
10+
})
11+
export class HistoryService extends ApiService {
12+
protected apiPath = 'history/history';
13+
14+
constructor(private http: HttpClient) {
15+
super();
16+
}
17+
18+
getHistories(): Observable<MatchingHistory[]> {
19+
return this.http.get<historyResponse>(`${this.apiUrl}`).pipe(
20+
map(response =>
21+
response.data.map(item => ({
22+
id: item._id,
23+
roomId: item.roomId,
24+
collaborator: item.collaborator.username,
25+
question: item.question,
26+
topics: item.question.topics,
27+
difficulty: item.question.difficulty,
28+
status: item.status,
29+
time: item.createdAt,
30+
language: item.snapshot?.language,
31+
code: item.snapshot?.code,
32+
})),
33+
),
34+
);
35+
}
36+
}

frontend/src/app/account/account.component.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { NgModule } from '@angular/core';
22
import { Routes, RouterModule } from '@angular/router';
33

4-
import { LoginComponent } from './login.component';
5-
import { RegisterComponent } from './register.component';
4+
import { LoginComponent } from './login/login.component';
5+
import { RegisterComponent } from './register/register.component';
66
import { LayoutComponent } from './layout.component';
7+
import { ProfileComponent } from './profile/profile.component';
8+
import { HistoryComponent } from './history/history.component';
79

810
const routes: Routes = [
911
{
@@ -13,6 +15,8 @@ const routes: Routes = [
1315
{ path: '', redirectTo: 'login', pathMatch: 'full' },
1416
{ path: 'login', component: LoginComponent },
1517
{ path: 'register', component: RegisterComponent },
18+
{ path: 'profile', component: ProfileComponent },
19+
{ path: 'history', component: HistoryComponent },
1620
],
1721
},
1822
];

frontend/src/app/account/account.module.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ import { NgModule } from '@angular/core';
22
import { ReactiveFormsModule } from '@angular/forms';
33
import { CommonModule } from '@angular/common';
44

5-
import { LoginComponent } from './login.component';
6-
import { RegisterComponent } from './register.component';
5+
import { LoginComponent } from './login/login.component';
6+
import { RegisterComponent } from './register/register.component';
77
import { LayoutComponent } from './layout.component';
88
import { AccountRoutingModule } from './account.component';
9+
import { ProfileComponent } from './profile/profile.component';
10+
import { HistoryComponent } from './history/history.component';
911

1012
@NgModule({
1113
imports: [
@@ -15,6 +17,8 @@ import { AccountRoutingModule } from './account.component';
1517
LayoutComponent,
1618
LoginComponent,
1719
RegisterComponent,
20+
ProfileComponent,
21+
HistoryComponent,
1822
],
1923
})
2024
export class AccountModule {}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
.sliding-panel {
2+
position: fixed;
3+
top: 0;
4+
right: -600px; /* Adjust the width as needed */
5+
width: 600px;
6+
height: 100%;
7+
background-color: #181818 !important;
8+
color: var(--text-color); /* Use theme variable */
9+
box-shadow: -2px 0 5px rgba(0,0,0,0.5);
10+
transition: right 0.3s ease;
11+
z-index: 1000;
12+
}
13+
14+
.sliding-panel.open {
15+
right: 0;
16+
}
17+
18+
.panel-header {
19+
display: flex;
20+
justify-content: space-between;
21+
align-items: center;
22+
padding: 1rem;
23+
background-color: #181818 !important;
24+
border-bottom: 1px solid #000000; /* Use theme variable */
25+
}
26+
27+
.panel-content {
28+
padding: 1rem;
29+
line-height: 1.6; /* Adjust line height for better readability */
30+
color: #ffffff; /* Ensure text color is readable */
31+
}
32+
33+
.panel-content p {
34+
margin-bottom: 1rem; /* Add margin to paragraphs for spacing */
35+
}
36+
37+
tr:hover {
38+
background-color: rgba(0, 0, 0, 0.1);
39+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
<div class="table-container">
2+
<p-table
3+
#dt
4+
sortField="time"
5+
[sortOrder]="1"
6+
[value]="histories"
7+
datakey="id"
8+
[tableStyle]="{ 'table-layout': 'auto', width: '100%', 'text-align': 'center' }"
9+
[paginator]="true"
10+
[rows]="10"
11+
[rowsPerPageOptions]="[10, 25, 50]"
12+
[globalFilterFields]="['question', 'difficulty', 'topics', 'collaborator', 'status', 'time']"
13+
styleClass="p-datatable-gridlines-striped">
14+
<ng-template pTemplate="caption">
15+
<div class="flex">
16+
<h3 class="m-0">Matching History</h3>
17+
</div>
18+
</ng-template>
19+
<ng-template pTemplate="caption">
20+
<div class="flex">
21+
<p-iconField iconPosition="left" class="ml-auto">
22+
<p-inputIcon>
23+
<i class="pi pi-search"></i>
24+
</p-inputIcon>
25+
<input
26+
pInputText
27+
type="text"
28+
(input)="dt.filterGlobal($any($event.target).value, 'contains')"
29+
placeholder="Search keyword" />
30+
</p-iconField>
31+
</div>
32+
</ng-template>
33+
<ng-template pTemplate="header" let-columns>
34+
<tr>
35+
<th pSortableColumn="question" style="width: 20%">
36+
Question<p-sortIcon field="question"></p-sortIcon>
37+
</th>
38+
<th pSortableColumn="difficulty" style="width: 14%">
39+
Difficulty<p-sortIcon field="difficulty"></p-sortIcon>
40+
</th>
41+
<th pSortableColumn="topics" style="width: 25%">Topics<p-sortIcon field="topics"></p-sortIcon></th>
42+
<th pSortableColumn="collaborator" style="width: 17%">
43+
Collaborator<p-sortIcon field="collaborator"></p-sortIcon>
44+
</th>
45+
<th pSortableColumn="status" style="width: 12%">Status<p-sortIcon field="status"></p-sortIcon></th>
46+
<th pSortableColumn="time" style="width: 12%">Time<p-sortIcon field="time"></p-sortIcon></th>
47+
</tr>
48+
</ng-template>
49+
<ng-template pTemplate="body" let-history>
50+
<tr (click)="onRowSelect(history)">
51+
<td>{{ history.question.title }}</td>
52+
<td>{{ history.difficulty }}</td>
53+
<td>{{ history.topics.join(', ') }}</td>
54+
<td>{{ history.collaborator }}</td>
55+
<td>
56+
@if (history.status === 'COMPLETED') {
57+
<i class="pi pi-check" style="color: green; font-size: large"></i>
58+
} @else if (history.status === 'FORFEITED') {
59+
<i class="pi pi-times" style="color: red; font-size: large"></i>
60+
} @else if (history.status === 'IN_PROGRESS') {
61+
<i class="pi pi-spin pi-spinner" style="color: white; font-size: large"></i>
62+
}
63+
</td>
64+
<td>{{ history.time }}</td>
65+
</tr>
66+
</ng-template>
67+
</p-table>
68+
</div>
69+
<div class="sliding-panel" [class.open]="isPanelVisible">
70+
<div class="panel-header">
71+
<h4>{{ panelHistory?.question?.title }}</h4>
72+
<p-button
73+
icon="pi pi-times"
74+
severity="secondary"
75+
label="Close"
76+
(onClick)="closePanel()"
77+
class="p-button-text" />
78+
</div>
79+
<div class="panel-content">
80+
<p>{{ panelHistory?.question?.description }}</p>
81+
<div #editor class="editor-content"></div>
82+
</div>
83+
</div>
84+
<p-toast position="bottom-right" [breakpoints]="{ '920px': { width: '90%' } }" />

0 commit comments

Comments
 (0)