Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
a785009
this is for uploading and downloading certificate template
emmanuelbaa Dec 17, 2025
09e48f0
commit to preview uploaded template
emmanuelbaa Dec 17, 2025
903c74e
commit
emmanuelbaa Dec 17, 2025
76cc154
enhanced css for styling.
emmanuelbaa Dec 17, 2025
5c82cfa
enhancement to remove button
emmanuelbaa Dec 17, 2025
ba0e85c
commit
emmanuelbaa Dec 17, 2025
183c081
commit
emmanuelbaa Dec 17, 2025
0624c79
commit
emmanuelbaa Dec 18, 2025
786f0ac
Update certifications-add.component.html
emmanuelbaa Dec 18, 2025
60fd86b
commit
emmanuelbaa Dec 18, 2025
24ef588
feat: Implement certificate background image from template
emmanuelbaa Dec 22, 2025
fdcc297
refactor: Remove localStorage for certificate attachments
emmanuelbaa Dec 22, 2025
6aa19ab
feat: Persist certificate attachments in CouchDB on selection
emmanuelbaa Dec 22, 2025
3858c47
fix(attachments): Correct getAttachment method in CouchService
emmanuelbaa Dec 22, 2025
fb4dfc6
refactor(certifications): Align attachment handling with edit profile
emmanuelbaa Dec 22, 2025
317aec4
fix(certifications): Add image preview to certifications add/edit page
emmanuelbaa Dec 22, 2025
5915d97
revert: Remove on-page preview for certificate template
emmanuelbaa Dec 22, 2025
0bae206
fix(certifications): Restore image preview to fix persistence issue
emmanuelbaa Dec 22, 2025
85d792f
commit
emmanuelbaa Dec 22, 2025
3ca097b
fix(certifications): Implement robust attachment handling
emmanuelbaa Dec 22, 2025
8bc111e
commit
emmanuelbaa Dec 22, 2025
18eeb84
commit
emmanuelbaa Dec 22, 2025
f531f56
comit
emmanuelbaa Dec 22, 2025
702195d
commit
emmanuelbaa Dec 22, 2025
f3d7fb8
this is for uploading and downloading certificate template
emmanuelbaa Dec 17, 2025
20e9cb5
commit to preview uploaded template
emmanuelbaa Dec 17, 2025
3fa485b
commit
emmanuelbaa Dec 17, 2025
c7bf003
enhanced css for styling.
emmanuelbaa Dec 17, 2025
bd17bfc
enhancement to remove button
emmanuelbaa Dec 17, 2025
71f63ed
commit
emmanuelbaa Dec 17, 2025
2db8cb7
commit
emmanuelbaa Dec 17, 2025
b214b78
commit
emmanuelbaa Dec 18, 2025
195abbf
Update certifications-add.component.html
emmanuelbaa Dec 18, 2025
5e2cd9f
commit
emmanuelbaa Dec 18, 2025
f1a5b0e
feat: Implement certificate background image from template
emmanuelbaa Dec 22, 2025
431da01
refactor: Remove localStorage for certificate attachments
emmanuelbaa Dec 22, 2025
4839ec3
feat: Persist certificate attachments in CouchDB on selection
emmanuelbaa Dec 22, 2025
5396de3
fix(attachments): Correct getAttachment method in CouchService
emmanuelbaa Dec 22, 2025
ed867e1
refactor(certifications): Align attachment handling with edit profile
emmanuelbaa Dec 22, 2025
1d799f9
fix(certifications): Add image preview to certifications add/edit page
emmanuelbaa Dec 22, 2025
adad1df
revert: Remove on-page preview for certificate template
emmanuelbaa Dec 22, 2025
d2b835b
fix(certifications): Restore image preview to fix persistence issue
emmanuelbaa Dec 22, 2025
b13b75b
commit
emmanuelbaa Dec 22, 2025
7a59fdb
fix(certifications): Implement robust attachment handling
emmanuelbaa Dec 22, 2025
cd8f7bd
commit
emmanuelbaa Dec 22, 2025
1ddc578
commit
emmanuelbaa Dec 22, 2025
821aaef
comit
emmanuelbaa Dec 22, 2025
643f5b3
commit
emmanuelbaa Dec 22, 2025
c81367d
Merge branch 'feature/certificate-download' of https://github.com/ope…
emmanuelbaa Dec 23, 2025
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
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
<button mat-raised-button color="primary" type="button" (click)="submitCertificate(true)" i18n class="margin-lr-5">Save & Return</button>
<button mat-raised-button color="accent" type="button" (click)="openCourseDialog()" i18n>Add Courses</button>
<button mat-raised-button color="accent" type="button" [disabled]="disableRemove" (click)="removeCourses()" i18n>Remove Courses</button>
<button mat-raised-button color="primary" type="button" (click)="openImagePreviewDialog()">
<span *ngIf="previewSrc && previewSrc !== 'assets/image.png'" i18n>Change Template</span>
<span *ngIf="!previewSrc || previewSrc === 'assets/image.png'" i18n>Upload a certificate template or pdf</span>
</button>
</div>
<form [formGroup]="certificateForm" novalidate>
<mat-form-field class="full-width">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Component, OnInit, ViewChild, AfterViewChecked, ChangeDetectorRef } from '@angular/core';
import { Component, OnInit, ViewChild, AfterViewChecked, ChangeDetectorRef, HostListener } from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms';
import { Router, ActivatedRoute, ParamMap } from '@angular/router';
import { MatLegacyDialog as MatDialog } from '@angular/material/legacy-dialog';
Expand All @@ -9,6 +9,10 @@ import { CoursesComponent } from '../../courses/courses.component';
import { showFormErrors } from '../../shared/table-helpers';
import { ValidatorService } from '../../validators/validator.service';
import { PlanetMessageService } from '../../shared/planet-message.service';
import { ImagePreviewDialogComponent } from '../../shared/dialogs/image-preview-dialog.component';
import { environment } from '../../../environments/environment';
import { CanComponentDeactivate } from '../../shared/unsaved-changes.guard';
import { warningMsg } from '../../shared/unsaved-changes.component';

interface CertificationFormModel {
name: string;
Expand All @@ -17,14 +21,22 @@ interface CertificationFormModel {
@Component({
templateUrl: './certifications-add.component.html'
})
export class CertificationsAddComponent implements OnInit, AfterViewChecked {
export class CertificationsAddComponent implements OnInit, AfterViewChecked, CanComponentDeactivate {

readonly dbName = 'certifications';
certificateInfo: { _id?: string, _rev?: string } = {};
certificateForm: FormGroup;
courseIds: string[] = [];
pageType = 'Add';
disableRemove = true;
selectedFile: any; // Will hold base64 string for new files
previewSrc: any = 'assets/image.png'; // For image preview
urlPrefix = environment.couchAddress + '/' + this.dbName + '/';

initialFormValues: any;
attachmentChanged = false;
isFormInitialized = false;

@ViewChild(CoursesComponent) courseTable: CoursesComponent;

constructor(
Expand Down Expand Up @@ -54,17 +66,31 @@ export class CertificationsAddComponent implements OnInit, AfterViewChecked {
this.certificateInfo._id = id;
this.certificationsService.getCertification(id).subscribe(certification => {
this.certificateForm.patchValue({ name: certification.name || '' } as Partial<CertificationFormModel>);
this.initialFormValues = { ...this.certificateForm.value };
this.certificateInfo._rev = certification._rev;
this.courseIds = certification.courseIds || [];
this.pageType = 'Update';
if (certification._attachments && certification._attachments.attachment) {
// Construct direct URL for the preview
this.previewSrc = `${this.urlPrefix}${id}/attachment`;
}
this.isFormInitialized = true;
this.setupFormValueChanges();
});
} else {
this.certificateInfo._id = undefined;
this.courseIds = [];
this.initialFormValues = { ...this.certificateForm.value };
this.isFormInitialized = true;
this.setupFormValueChanges();
}
});
}

setupFormValueChanges() {
this.certificateForm.valueChanges.subscribe(() => {
// The guard will check for changes, no need to set a flag here
});
}

ngAfterViewChecked() {
const disableRemove = !this.courseTable || !this.courseTable.selection.selected.length;
if (this.disableRemove !== disableRemove) {
Expand All @@ -78,18 +104,67 @@ export class CertificationsAddComponent implements OnInit, AfterViewChecked {
this.router.navigate([ navigation ], { relativeTo: this.route });
}

openImagePreviewDialog() {
const dialogRef = this.dialog.open(ImagePreviewDialogComponent, {
data: { file: this.previewSrc } // Pass previewSrc to the dialog
});

dialogRef.afterClosed().subscribe(result => {
if (result === undefined) {
return; // Dialog was closed without action
}
this.attachmentChanged = true; // Any confirmed action in the dialog is a change
if (result instanceof File) {
// New file selected, convert to base64
const reader = new FileReader();
reader.onload = () => {
this.selectedFile = reader.result as string;
this.previewSrc = this.selectedFile;
};
reader.readAsDataURL(result);
} else if (result === null) {
// Image removed
this.selectedFile = null;
this.previewSrc = 'assets/image.png';
}
});
}

submitCertificate(reroute: boolean) {
if (!this.certificateForm.valid) {
showFormErrors(this.certificateForm.controls);
return;
}
const certificateFormValue: CertificationFormModel = this.certificateForm.getRawValue();
this.certificationsService.addCertification({
const certData: any = {
...this.certificateInfo,
...certificateFormValue,
courseIds: this.courseIds
}).subscribe((res) => {
courseIds: this.courseIds,
};

if (this.selectedFile && this.selectedFile.startsWith('data:')) {
// New base64 image data is present
const imgDataArr: string[] = this.selectedFile.split(/;\w+,/);
const contentType: string = imgDataArr[0].replace(/data:/, '');
const data: string = imgDataArr[1];
certData._attachments = {
'attachment': {
'content_type': contentType,
'data': data
}
};
} else if (this.attachmentChanged && !this.selectedFile) {
// This means the image was removed
if (certData._attachments) {
delete certData._attachments;
}
}


this.certificationsService.addCertification(certData).subscribe((res) => {
this.certificateInfo = { _id: res.id, _rev: res.rev };
this.initialFormValues = { ...this.certificateForm.value };
this.attachmentChanged = false;
this.planetMessageService.showMessage(
this.pageType === 'Add' ? $localize`New certification added` : $localize`Certification updated`
);
Expand Down Expand Up @@ -119,4 +194,25 @@ export class CertificationsAddComponent implements OnInit, AfterViewChecked {
this.courseIds = this.courseIds.filter(id => this.courseTable.selection.selected.indexOf(id) === -1);
}

canDeactivate(): boolean {
return !this.getHasUnsavedChanges();
}

isFormPristine(): boolean {
return JSON.stringify(this.certificateForm.value) === JSON.stringify(this.initialFormValues);
}

@HostListener('window:beforeunload', [ '$event' ])
unloadNotification($event: BeforeUnloadEvent): void {
if (this.getHasUnsavedChanges()) {
$event.returnValue = warningMsg;
}
}

private getHasUnsavedChanges(): boolean {
if (!this.isFormInitialized) {
return false;
}
return !this.isFormPristine() || this.attachmentChanged;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export class CertificationsService {
}

addCertification(certification) {
return this.couchService.updateDocument(this.dbName, { ...certification });
return this.couchService.updateDocument(this.dbName, certification);
}

isCourseCompleted(course, user) {
Expand Down
93 changes: 60 additions & 33 deletions src/app/shared/couchdb.service.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { Injectable } from '@angular/core';
import { HttpHeaders, HttpClient, HttpRequest } from '@angular/common/http';
import { environment } from '../../environments/environment';
import { Observable, of, empty, throwError, forkJoin } from 'rxjs';
import { catchError, map, expand, toArray, flatMap, switchMap } from 'rxjs/operators';
import { Observable, of, empty, throwError, forkJoin } from 'rxjs';
import { catchError, map, expand, toArray, flatMap, switchMap } from 'rxjs/operators';
import { debug } from '../debug-operator';
import { PlanetMessageService } from './planet-message.service';
import { findDocuments } from './mangoQueries';
Expand All @@ -28,7 +28,8 @@ export class CouchService {
const url = (domain ? (protocol || environment.parentProtocol) + '://' + domain : this.baseUrl) + '/' + db;
let httpReq: Observable<any>;
if (type === 'post' || type === 'put') {
httpReq = this.http[type](url, data, opts);
const body = typeof data === 'object' && !(data instanceof Blob) ? JSON.stringify(data) : data;
httpReq = this.http[type](url, body, opts);
} else {
httpReq = this.http[type](url, opts);
}
Expand Down Expand Up @@ -68,10 +69,16 @@ export class CouchService {
return this.couchDBReq('delete', db, this.setOpts(opts));
}

putAttachment(db: string, file: FormData, opts?: any) {
return this.couchDBReq('put', db, this.setOpts(opts), file);
putAttachment(db: string, file: File, opts?: any) {
const httpOptions = {
headers: new HttpHeaders({
'Content-Type': file.type,
}),
withCredentials: true,
...opts,
};
return this.couchDBReq('put', db, this.setOpts(httpOptions), file);
}

updateDocument(db: string, doc: any, opts?: any) {
let docWithDate: any;
return this.currentTime().pipe(
Expand All @@ -97,33 +104,33 @@ export class CouchService {
}));
}

findAll(db: string, query: any = { 'selector': { '_id': { '$gt': null } }, 'limit': 1000 }, opts?: any) {
return this.findAllRequest(db, query, opts).pipe(flatMap(({ docs }) => docs), toArray());
}

findAllStream(db: string, query: any = { 'selector': { '_id': { '$gt': null } }, 'limit': 1000 }, opts?: any) {
return this.findAllRequest(db, query, opts).pipe(map(({ docs }) => docs));
}

findAttachmentsByIds(ids: string[], opts?: any) {
const uniqueIds = Array.from(new Set((ids || []).filter(id => !!id)));
if (uniqueIds.length === 0) {
return of([]);
}
const chunkSize = 50;
const queries = [];
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
const chunk = uniqueIds.slice(i, i + chunkSize);
queries.push(
this.findAll('attachments', findDocuments({ '_id': { '$in': chunk } }, 0, 0, chunk.length), opts)
);
}
return forkJoin(queries).pipe(
map((results: any[]) => results.reduce((acc: any[], docs: any[]) => acc.concat(docs), []))
);
}

private findAllRequest(db: string, query: any, opts: any) {
findAll(db: string, query: any = { 'selector': { '_id': { '$gt': null } }, 'limit': 1000 }, opts?: any) {
return this.findAllRequest(db, query, opts).pipe(flatMap(({ docs }) => docs), toArray());
}
findAllStream(db: string, query: any = { 'selector': { '_id': { '$gt': null } }, 'limit': 1000 }, opts?: any) {
return this.findAllRequest(db, query, opts).pipe(map(({ docs }) => docs));
}
findAttachmentsByIds(ids: string[], opts?: any) {
const uniqueIds = Array.from(new Set((ids || []).filter(id => !!id)));
if (uniqueIds.length === 0) {
return of([]);
}
const chunkSize = 50;
const queries = [];
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
const chunk = uniqueIds.slice(i, i + chunkSize);
queries.push(
this.findAll('attachments', findDocuments({ '_id': { '$in': chunk } }, 0, 0, chunk.length), opts)
);
}
return forkJoin(queries).pipe(
map((results: any[]) => results.reduce((acc: any[], docs: any[]) => acc.concat(docs), []))
);
}
private findAllRequest(db: string, query: any, opts: any) {
return this.post(db + '/_find', query, opts).pipe(
catchError(() => {
return of({ docs: [], rows: [] });
Expand Down Expand Up @@ -225,4 +232,24 @@ export class CouchService {
);
}

getAttachment(db: string, docId: string, attachmentId: string, opts?: any): Observable<any> {
const url = `${this.baseUrl}/${db}/${docId}/${attachmentId}`;
const httpOptions = {
...this.defaultOpts,
responseType: 'blob',
...opts,
};
// Use http.get directly instead of couchDBReq because couchDBReq is not suitable for attachment URLs
return this.http.get(url, httpOptions).pipe(
catchError(err => {
if (err.status === 403) {
this.planetMessageService.showAlert($localize`You are not authorized. Please contact administrator.`);
} else {
this.planetMessageService.showAlert($localize`Error fetching attachment: ${err.message}`);
}
return throwError(err);
})
);
}

}
17 changes: 17 additions & 0 deletions src/app/shared/dialogs/image-preview-dialog.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<h2 mat-dialog-title>Image Preview</h2>
<mat-dialog-content>
<div *ngIf="previewUrl; else noImage">
<img [src]="previewUrl" style="max-width: 100%; max-height: 50vh;">
</div>
<ng-template #noImage>
<p>No image selected.</p>
</ng-template>
</mat-dialog-content>
<mat-dialog-actions>
<input type="file" #fileInput (change)="onFileSelected($event)" hidden>
<button mat-button (click)="fileInput.click()">Select</button>
<button mat-button (click)="remove()">Remove</button>
<span style="flex: 1 1 auto;"></span>
<button mat-button (click)="close()">Cancel</button>
<button mat-raised-button color="primary" (click)="confirm()">Confirm</button>
</mat-dialog-actions>
Loading
Loading