Skip to content

Commit 1d43e1b

Browse files
Mutugiiidogi
andauthored
life: smoother achievements resume attaching (fixes #9782) (#9789)
Co-authored-by: dogi <dogi@users.noreply.github.com>
1 parent 65c319e commit 1d43e1b

10 files changed

Lines changed: 179 additions & 50 deletions

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
{
22
"name": "planet",
33
"license": "AGPL-3.0",
4-
"version": "0.22.80",
4+
"version": "0.22.81",
55
"myplanet": {
6-
"latest": "v0.54.93",
6+
"latest": "v0.54.94",
77
"min": "v0.52.45"
88
},
99
"scripts": {

src/app/shared/couchdb.service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ export class CouchService {
6666
return this.couchDBReq('delete', db, this.setOpts(opts));
6767
}
6868

69-
putAttachment(db: string, file: FormData, opts?: any) {
69+
putAttachment(db: string, file: File | FormData, opts?: any) {
7070
return this.couchDBReq('put', db, this.setOpts(opts), file);
7171
}
7272

src/app/shared/forms/file-input.component.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,29 @@
1-
import { Component, Output, EventEmitter, ViewChild } from '@angular/core';
1+
import { Component, Output, EventEmitter, ViewChild, Input, ElementRef } from '@angular/core';
22
import { truncateText } from '../../shared/utils';
33
import { MatButton } from '@angular/material/button';
44

55
@Component({
66
selector: 'planet-file-input',
77
template: `
8-
<div class="inner-gaps by-column">
8+
<div class="inner-gaps by-column file-input-container">
99
<button type="button" mat-raised-button (click)="fileInput.click()" color="primary" i18n>Choose File</button>
10-
<input hidden (change)="onFileSelected($event)" #fileInput type="file">
10+
<input hidden (change)="onFileSelected($event)" #fileInput type="file" [accept]="accept">
1111
<span class="file-name" i18n>{{ getTruncatedFileName() }}</span>
1212
</div>
1313
`,
14+
styles: [ `
15+
.file-name {
16+
display: inline-flex;
17+
align-items: center;
18+
}
19+
` ],
1420
imports: [MatButton]
1521
})
1622
export class FileInputComponent {
1723

24+
@Input() accept = '';
1825
@Output() fileChange = new EventEmitter<any>();
19-
@ViewChild('fileInput') fileInput!: HTMLInputElement;
26+
@ViewChild('fileInput') fileInput!: ElementRef<HTMLInputElement>;
2027

2128
selectedFile: any = null;
2229
onFileSelected(event: any): void {
@@ -34,9 +41,8 @@ export class FileInputComponent {
3441
clearFile() {
3542
this.selectedFile = null;
3643
if (this.fileInput) {
37-
this.fileInput.value = '';
44+
this.fileInput.nativeElement.value = '';
3845
}
3946
}
4047

4148
}
42-

src/app/users/users-achievements/users-achievements-update.component.html

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,24 @@
9797
<p class="warn-text-color mat-caption" *ngIf="sectionError('links')">{{ sectionError('links') }}</p>
9898
<button type="button" (click)="addLink()" mat-stroked-button color="primary" i18n>Enter a Link</button>
9999
</div>
100+
<div>
101+
<p class="mat-hint mat-caption" i18n>Upload your CV/Resume below (PDF only)</p>
102+
<div class="existing-file-container">
103+
<planet-file-input #resumeInput accept=".pdf,application/pdf" (fileChange)="onResumeSelected($event)"></planet-file-input>
104+
<button mat-icon-button type="button" *ngIf="resumeFile" (click)="clearResumeSelection()" i18n-matTooltip matTooltip="Remove new attachment">
105+
<mat-icon color="warn">delete</mat-icon>
106+
</button>
107+
</div>
108+
<div class="existing-file-container" *ngIf="currentResumeFileName">
109+
<p class="mat-caption existing-resume">
110+
<ng-container i18n>Current CV/Resume:</ng-container> {{ currentResumeFileName }}
111+
</p>
112+
<button mat-icon-button type="button" (click)="removeExistingResume()" i18n-matTooltip matTooltip="Remove existing attachment">
113+
<mat-icon color="warn">delete</mat-icon>
114+
</button>
115+
</div>
116+
<p class="warn-text-color mat-caption" *ngIf="resumeUploadError">{{ resumeUploadError }}</p>
117+
</div>
100118
<mat-checkbox formControlName="sendToNation" class="full-width achievements-checkbox" i18n>
101119
Allow your achievements to be shared with the nation
102120
</mat-checkbox>

src/app/users/users-achievements/users-achievements-update.component.ts

Lines changed: 115 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { Component, OnInit, ViewEncapsulation, OnDestroy, HostListener } from '@angular/core';
1+
import { Component, OnInit, ViewEncapsulation, OnDestroy, HostListener, ViewChild } from '@angular/core';
22
import { FormArray, FormControl, FormGroup, NonNullableFormBuilder, Validators, FormsModule, ReactiveFormsModule } from '@angular/forms';
33
import { ActivatedRoute, Router } from '@angular/router';
44
import { combineLatest, forkJoin, Subject, interval, of, race } from 'rxjs';
5-
import { catchError, takeUntil, debounce, filter, startWith, take } from 'rxjs/operators';
5+
import { catchError, takeUntil, debounce, filter, startWith, take, switchMap } from 'rxjs/operators';
66
import { CouchService } from '../../shared/couchdb.service';
77
import { UserService } from '../../shared/user.service';
88
import { PlanetMessageService } from '../../shared/planet-message.service';
@@ -15,6 +15,7 @@ import { PlanetStepListService, PlanetStepListComponent, PlanetStepListItemCompo
1515
import { showFormErrors } from '../../shared/table-helpers';
1616
import { CanComponentDeactivate } from '../../shared/unsaved-changes.guard';
1717
import { warningMsg } from '../../shared/unsaved-changes.component';
18+
import { FileInputComponent } from '../../shared/forms/file-input.component';
1819
import { MatToolbar } from '@angular/material/toolbar';
1920
import { MatIconButton, MatAnchor, MatButton } from '@angular/material/button';
2021
import { MatIcon } from '@angular/material/icon';
@@ -82,22 +83,30 @@ type LinkFormGroup = FormGroup<LinkFormControls>;
8283
MatToolbar, MatIconButton, MatIcon, NgIf, FormsModule, ReactiveFormsModule, MatFormField, MatLabel,
8384
MatInput, MatError, FormErrorMessagesComponent, MatDatepickerInput, MatDatepickerToggle, MatSuffix, MatDatepicker,
8485
PlanetMarkdownTextboxComponent, MatAnchor, NgSwitch, NgSwitchCase, PlanetStepListComponent, NgFor,
85-
PlanetStepListItemComponent, MatListItemTitle, MatListItemMeta, MatButton, MatCheckbox, SubmitDirective
86+
PlanetStepListItemComponent, MatListItemTitle, MatListItemMeta, MatButton, MatCheckbox, SubmitDirective, FileInputComponent
8687
]
8788
})
8889
export class UsersAchievementsUpdateComponent implements OnInit, OnDestroy, CanComponentDeactivate {
8990
user = this.userService.get();
9091
configuration = this.stateService.configuration;
9192
docInfo = { '_id': this.user._id + '@' + this.configuration.code, '_rev': undefined };
9293
readonly dbName = 'achievements';
94+
readonly resumeAttachmentKey = 'resume.pdf';
95+
readonly maxResumeSizeMb = 512;
9396
achievementNotFound = false;
9497
editForm!: FormGroup<EditFormControls>;
9598
profileForm!: FormGroup<ProfileFormControls>;
9699
private onDestroy$ = new Subject<void>();
97100
initialFormValues: any;
98101
hasUnsavedChanges = false;
99102
submitAttempted = false;
103+
currentResumeFileName = '';
104+
resumeFile: File | null = null;
105+
resumeUploadError = '';
106+
resumeMarkedForDeletion = false;
107+
existingResumeAttachment: any = null;
100108
private submitAfterPending = false;
109+
@ViewChild('resumeInput') resumeInput?: FileInputComponent;
101110
get achievements(): FormArray<AchievementFormGroup> {
102111
return this.editForm.controls.achievements;
103112
}
@@ -145,6 +154,9 @@ export class UsersAchievementsUpdateComponent implements OnInit, OnDestroy, CanC
145154
this.editForm.setControl('links', this.buildLinksFormArray(achievements.links));
146155
// Keeping older otherInfo property so we don't lose this info on database
147156
this.editForm.setControl('otherInfo', this.buildOtherInfoFormArray(achievements.otherInfo));
157+
this.currentResumeFileName = achievements.resumeFileName || (achievements._attachments?.[this.resumeAttachmentKey] ?
158+
this.resumeAttachmentKey : '');
159+
this.existingResumeAttachment = achievements._attachments?.[this.resumeAttachmentKey] || null;
148160

149161
if (this.docInfo._id === achievements._id) {
150162
this.docInfo._rev = achievements._rev;
@@ -164,11 +176,7 @@ export class UsersAchievementsUpdateComponent implements OnInit, OnDestroy, CanC
164176
}
165177

166178
private captureInitialState() {
167-
const editFormState = this.editForm.getRawValue();
168-
this.initialFormValues = JSON.stringify({
169-
editForm: editFormState,
170-
profileForm: this.profileForm.getRawValue()
171-
});
179+
this.initialFormValues = this.getCurrentState();
172180
}
173181

174182
onFormChanges() {
@@ -269,12 +277,19 @@ export class UsersAchievementsUpdateComponent implements OnInit, OnDestroy, CanC
269277
}
270278

271279
private updateUnsavedChangesFlag() {
272-
const editFormState = this.editForm.getRawValue();
273-
const currentState = JSON.stringify({
274-
editForm: editFormState,
275-
profileForm: this.profileForm.getRawValue()
280+
this.hasUnsavedChanges = this.getCurrentState() !== this.initialFormValues;
281+
}
282+
283+
private getCurrentState() {
284+
return JSON.stringify({
285+
editForm: this.editForm.getRawValue(),
286+
profileForm: this.profileForm.getRawValue(),
287+
resume: {
288+
fileName: this.currentResumeFileName,
289+
hasPendingUpload: !!this.resumeFile,
290+
markedForDeletion: this.resumeMarkedForDeletion
291+
}
276292
});
277-
this.hasUnsavedChanges = currentState !== this.initialFormValues;
278293
}
279294

280295
addAchievement(index = -1, achievement = { title: '', description: '', link: '', date: '' }) {
@@ -364,8 +379,58 @@ export class UsersAchievementsUpdateComponent implements OnInit, OnDestroy, CanC
364379
});
365380
}
366381

382+
onResumeSelected(event: Event) {
383+
const input = event.target as HTMLInputElement;
384+
const file = input.files?.[0] ?? null;
385+
if (!file) {
386+
this.resumeFile = null;
387+
this.resumeUploadError = '';
388+
this.updateUnsavedChangesFlag();
389+
return;
390+
}
391+
392+
const isPdf = file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf');
393+
if (!isPdf) {
394+
this.resumeFile = null;
395+
this.resumeUploadError = $localize`Please select a PDF file`;
396+
this.resumeInput?.clearFile();
397+
this.updateUnsavedChangesFlag();
398+
return;
399+
}
400+
401+
if (file.size / 1024 / 1024 > this.maxResumeSizeMb) {
402+
this.resumeFile = null;
403+
this.resumeUploadError = $localize`Please select a PDF file smaller than ${this.maxResumeSizeMb} MB`;
404+
this.resumeInput?.clearFile();
405+
this.updateUnsavedChangesFlag();
406+
return;
407+
}
408+
409+
this.resumeFile = file;
410+
this.resumeUploadError = '';
411+
this.resumeMarkedForDeletion = false;
412+
this.updateUnsavedChangesFlag();
413+
}
414+
415+
clearResumeSelection() {
416+
this.resumeFile = null;
417+
this.resumeUploadError = '';
418+
this.resumeInput?.clearFile();
419+
this.updateUnsavedChangesFlag();
420+
}
421+
422+
removeExistingResume() {
423+
this.resumeMarkedForDeletion = true;
424+
this.currentResumeFileName = '';
425+
this.clearResumeSelection();
426+
}
427+
367428
onSubmit() {
368429
this.submitAttempted = true;
430+
if (this.resumeUploadError) {
431+
this.planetMessageService.showAlert($localize`Please upload your CV/Resume as a PDF file`);
432+
return;
433+
}
369434
if (this.editForm.pending || this.profileForm.pending) {
370435
if (this.submitAfterPending) {
371436
return;
@@ -428,12 +493,43 @@ export class UsersAchievementsUpdateComponent implements OnInit, OnDestroy, CanC
428493
}
429494

430495
updateAchievements(docInfo, achievements, userInfo) {
431-
// ...is the rest syntax for object destructuring
432-
forkJoin([
433-
this.couchService.post(this.dbName, { ...docInfo, ...achievements,
434-
'createdOn': this.configuration.code, 'username': this.user.name, 'parentCode': this.configuration.parentCode }),
435-
this.userService.updateUser(userInfo)
436-
]).subscribe(() => {
496+
const achievementsDoc: any = {
497+
...docInfo,
498+
...achievements,
499+
'createdOn': this.configuration.code,
500+
'username': this.user.name,
501+
'parentCode': this.configuration.parentCode
502+
};
503+
504+
if (this.resumeFile) {
505+
achievementsDoc.resumeFileName = this.resumeFile.name;
506+
if (this.existingResumeAttachment) {
507+
achievementsDoc._attachments = {
508+
[this.resumeAttachmentKey]: this.existingResumeAttachment
509+
};
510+
}
511+
} else if (!this.resumeMarkedForDeletion && this.currentResumeFileName) {
512+
achievementsDoc.resumeFileName = this.currentResumeFileName;
513+
if (this.existingResumeAttachment) {
514+
achievementsDoc._attachments = {
515+
[this.resumeAttachmentKey]: this.existingResumeAttachment
516+
};
517+
}
518+
}
519+
520+
this.couchService.post(this.dbName, achievementsDoc).pipe(
521+
switchMap((achievementsRes) => forkJoin([
522+
this.resumeFile ?
523+
this.couchService.putAttachment(
524+
this.dbName + '/' + achievementsRes.id + '/' + this.resumeAttachmentKey + '?rev=' + achievementsRes.rev,
525+
this.resumeFile, { headers: { 'Content-Type': this.resumeFile.type } }
526+
) :
527+
of({}),
528+
this.userService.updateUser(userInfo)
529+
]))
530+
).subscribe(() => {
531+
this.resumeFile = null;
532+
this.resumeMarkedForDeletion = false;
437533
this.planetMessageService.showMessage($localize`Achievements successfully updated`);
438534
this.goBack();
439535
}, (err) => {

src/app/users/users-achievements/users-achievements-update.scss

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@
2222
width: 100%;
2323
}
2424

25+
.existing-file-container {
26+
display: flex;
27+
align-items: center;
28+
}
29+
2530
.achievements-checkbox {
2631
margin-top: 15px;
2732
margin-bottom: 15px;

src/app/users/users-achievements/users-achievements.component.html

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,17 @@
55
</mat-toolbar>
66

77
<div class="space-container">
8-
<mat-toolbar class="primary-color font-size-1 responsive-toolbar center-text">
8+
<mat-toolbar class="primary-color font-size-1 responsive-toolbar">
99
<div>
10-
<span *ngIf="user?.firstName; else elseBlock" class="center-text">{{ (user.firstName + ' ' + user.middleName + ' ' + user.lastName) | truncateText:40 }}</span>
10+
<span *ngIf="user?.firstName; else elseBlock">{{ (user.firstName + ' ' + user.middleName + ' ' + user.lastName) | truncateText:40 }}</span>
1111
<ng-template #elseBlock>{{ user.name | truncateText:40 }}</ng-template>
1212
</div>
1313
<span class="toolbar-fill"></span>
1414
<div class="auto-adjust-buttons">
15-
<a mat-raised-button color="primary" class="margin-r-1" *ngIf="ownAchievements && !achievementNotFound" (click)="generatePDF()">
15+
<a mat-stroked-button class="margin-r-1" *ngIf="resumeUrl" [href]="resumeUrl" target="_blank" rel="noopener noreferrer" i18n>
16+
View CV/Resume
17+
</a>
18+
<a mat-stroked-button class="margin-r-1" *ngIf="ownAchievements && !achievementNotFound" (click)="generatePDF()">
1619
<span i18n>Print Achievements</span>
1720
</a>
1821
<a mat-raised-button color="accent" routerLink="update" *ngIf="ownAchievements">

src/app/users/users-achievements/users-achievements.component.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ pdfMake.addVirtualFileSystem(pdfFonts);
3838
]
3939
})
4040
export class UsersAchievementsComponent implements OnInit {
41+
readonly dbName = 'achievements';
42+
readonly resumeAttachmentKey = 'resume.pdf';
4143
user: any = {};
4244
achievements: any;
4345
achievementNotFound = false;
@@ -141,6 +143,14 @@ export class UsersAchievementsComponent implements OnInit {
141143
this.openAchievementIndex = this.openAchievementIndex === index ? -1 : index;
142144
}
143145

146+
147+
get resumeUrl() {
148+
if (!this.achievements?._attachments?.[this.resumeAttachmentKey] || !this.achievements?._id) {
149+
return '';
150+
}
151+
return `${environment.couchAddress}/${this.dbName}/${this.achievements._id}/${this.resumeAttachmentKey}`;
152+
}
153+
144154
get profileImg() {
145155
const attachments = this.userService.get()._attachments;
146156
if (attachments) {

0 commit comments

Comments
 (0)