diff --git a/apps/picsa-apps/dashboard/src/app/modules/monitoring/monitoring-forms.module.ts b/apps/picsa-apps/dashboard/src/app/modules/monitoring/monitoring-forms.module.ts index 3b7c1090f..17c00ba3a 100644 --- a/apps/picsa-apps/dashboard/src/app/modules/monitoring/monitoring-forms.module.ts +++ b/apps/picsa-apps/dashboard/src/app/modules/monitoring/monitoring-forms.module.ts @@ -4,6 +4,7 @@ import { RouterModule } from '@angular/router'; import { FormSubmissionsComponent } from './pages/form-submissions/form-submissions.component'; import { MonitoringPageComponent } from './pages/home/monitoring.page'; +import { UpdateMonitoringFormsComponent } from './pages/update/update-monitoring-forms.component'; import { ViewMonitoringFormsComponent } from './pages/view/view-monitoring-forms.component'; @NgModule({ @@ -23,6 +24,10 @@ import { ViewMonitoringFormsComponent } from './pages/view/view-monitoring-forms path: ':id/submissions', component: FormSubmissionsComponent, }, + { + path: ':id/edit', + component: UpdateMonitoringFormsComponent, + }, ]), ], }) diff --git a/apps/picsa-apps/dashboard/src/app/modules/monitoring/monitoring.service.ts b/apps/picsa-apps/dashboard/src/app/modules/monitoring/monitoring.service.ts index 1e3d9daca..3e92dbd8f 100644 --- a/apps/picsa-apps/dashboard/src/app/modules/monitoring/monitoring.service.ts +++ b/apps/picsa-apps/dashboard/src/app/modules/monitoring/monitoring.service.ts @@ -1,9 +1,12 @@ +import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; // eslint-disable-next-line @nx/enforce-module-boundaries import { Database } from '@picsa/server-types'; import { PicsaAsyncService } from '@picsa/shared/services/asyncService.service'; +import { PicsaNotificationService } from '@picsa/shared/services/core/notification.service'; import { SupabaseService } from '@picsa/shared/services/core/supabase'; import { IStorageEntry } from '@picsa/shared/services/core/supabase/services/supabase-storage.service'; +import { firstValueFrom, Observable } from 'rxjs'; export type IMonitoringFormsRow = Database['public']['Tables']['monitoring_forms']['Row']; @@ -21,7 +24,11 @@ export class MonitoringFormsDashboardService extends PicsaAsyncService { return this.supabaseService.db.table(this.TABLE_NAME); } - constructor(private supabaseService: SupabaseService) { + constructor( + private supabaseService: SupabaseService, + private http: HttpClient, + private notificationService: PicsaNotificationService + ) { super(); } @@ -57,4 +64,74 @@ export class MonitoringFormsDashboardService extends PicsaAsyncService { } return { data, error }; } + + public async updateFormById(id: string, updatedForm: Partial): Promise { + const { data, error } = await this.supabaseService.db + .table(this.TABLE_NAME) + .update(updatedForm) + .eq('id', id) + .select() + .single(); + if (error) { + throw error; + } + return data; + } + + /** + * Convert an xls form to xml-xform standard + * @param file xls file representation + * @returns xml string of converted form + */ + async submitFormToConvertXlsToXForm(file: File) { + const url = 'https://xform-converter.picsa.app/api/v1/convert'; + try { + const { result } = await firstValueFrom(this.http.post(url, file) as Observable); + return result; + } catch (error: any) { + console.error(error); + this.notificationService.showUserNotification({ matIcon: 'error', message: error?.message || error }); + return null; + } + } + /** + * Convert + * @param formData formData object with 'files' property that includes xml xform read as a File + * @returns enketo entry of converted xmlform + */ + async submitFormToConvertXFormToEnketo(formData: FormData) { + const url = 'https://enketo-converter.picsa.app/api/xlsform-to-enketo'; + try { + const { convertedFiles } = await firstValueFrom(this.http.post(url, formData) as Observable); + return convertedFiles[0]?.content; + } catch (error: any) { + console.error(error); + this.notificationService.showUserNotification({ matIcon: 'error', message: error?.message || error }); + return null; + } + } +} +/** Response model returned from xform-converter */ +interface XFormConvertRes { + /** http error if thrown */ + error: any; + /** xml string of converted */ + result: string; + /** https status code, 200 indicates success */ + status: number; +} +/** Response model returned from enketo-converter */ +interface IEnketoConvertRes { + convertedFiles: { + content: IEnketoConvertContent; + filename: string; + }[]; + message: string; +} +interface IEnketoConvertContent { + form: string; + languageMap: any; + model: string; + theme: string; + transformerVersion: string; } diff --git a/apps/picsa-apps/dashboard/src/app/modules/monitoring/pages/update/update-monitoring-forms.component.html b/apps/picsa-apps/dashboard/src/app/modules/monitoring/pages/update/update-monitoring-forms.component.html new file mode 100644 index 000000000..438f79c56 --- /dev/null +++ b/apps/picsa-apps/dashboard/src/app/modules/monitoring/pages/update/update-monitoring-forms.component.html @@ -0,0 +1,22 @@ +
+
+

Update Form

+
+ @if(form){ +
+

Upload new Form excel file

+ + +
+ } @if(updateFeedbackMessage) { +
{{ updateFeedbackMessage }}
+ } @if(uploading==true) { +
Uploading form...
+ } +
diff --git a/apps/picsa-apps/dashboard/src/app/modules/monitoring/pages/update/update-monitoring-forms.component.scss b/apps/picsa-apps/dashboard/src/app/modules/monitoring/pages/update/update-monitoring-forms.component.scss new file mode 100644 index 000000000..ae8bc9dc2 --- /dev/null +++ b/apps/picsa-apps/dashboard/src/app/modules/monitoring/pages/update/update-monitoring-forms.component.scss @@ -0,0 +1,28 @@ +.form-content { + display: flex; + flex-direction: column; + gap: 1.4rem; +} +.submitButton { + width: 7rem; + margin-bottom: 1rem; +} +.form-data { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.data-container { + margin-left: 2rem; + max-height: 25rem; + overflow-y: auto; +} +label { + font-weight: 700; +} +.action-button-section { + display: flex; + flex-direction: row; + gap: 5px; +} diff --git a/apps/picsa-apps/dashboard/src/app/modules/monitoring/pages/update/update-monitoring-forms.component.spec.ts b/apps/picsa-apps/dashboard/src/app/modules/monitoring/pages/update/update-monitoring-forms.component.spec.ts new file mode 100644 index 000000000..9039c82d0 --- /dev/null +++ b/apps/picsa-apps/dashboard/src/app/modules/monitoring/pages/update/update-monitoring-forms.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { UpdateMonitoringFormsComponent } from './update-monitoring-forms.component'; + +describe('UpdateMonitoringFormsComponent', () => { + let component: UpdateMonitoringFormsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [UpdateMonitoringFormsComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(UpdateMonitoringFormsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/apps/picsa-apps/dashboard/src/app/modules/monitoring/pages/update/update-monitoring-forms.component.ts b/apps/picsa-apps/dashboard/src/app/modules/monitoring/pages/update/update-monitoring-forms.component.ts new file mode 100644 index 000000000..39527da1f --- /dev/null +++ b/apps/picsa-apps/dashboard/src/app/modules/monitoring/pages/update/update-monitoring-forms.component.ts @@ -0,0 +1,103 @@ +import { CommonModule } from '@angular/common'; +import { Component, OnInit } from '@angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { ActivatedRoute, RouterModule } from '@angular/router'; +// eslint-disable-next-line @nx/enforce-module-boundaries +import type { Database } from '@picsa/server-types'; +import { + IUploadResult, + SupabaseStoragePickerDirective, + SupabaseUploadComponent, +} from '@picsa/shared/services/core/supabase'; +import { SupabaseStorageService } from '@picsa/shared/services/core/supabase/services/supabase-storage.service'; +import { NgxJsonViewerModule } from 'ngx-json-viewer'; + +import { DashboardMaterialModule } from '../../../../material.module'; +import { MonitoringFormsDashboardService } from '../../monitoring.service'; + +export type IMonitoringFormsRow = Database['public']['Tables']['monitoring_forms']['Row']; + +@Component({ + selector: 'dashboard-monitoring-update', + standalone: true, + imports: [ + CommonModule, + DashboardMaterialModule, + FormsModule, + ReactiveFormsModule, + RouterModule, + NgxJsonViewerModule, + SupabaseUploadComponent, + SupabaseStoragePickerDirective, + ], + templateUrl: './update-monitoring-forms.component.html', + styleUrls: ['./update-monitoring-forms.component.scss'], +}) +export class UpdateMonitoringFormsComponent implements OnInit { + public form: IMonitoringFormsRow; + public updateFeedbackMessage = ''; + public uploading = false; + public allowedFileTypes = ['xlsx', 'xls'].map((ext) => `.${ext}`); + public storageBucketName = 'global'; + public storageFolderPath = 'monitoring/forms'; + constructor( + private service: MonitoringFormsDashboardService, + private route: ActivatedRoute, + private storageService: SupabaseStorageService + ) {} + async ngOnInit() { + await this.service.ready(); + this.route.params.subscribe(async (params) => { + const id = params['id']; + this.service + .getFormById(id) + .then((data) => { + this.form = data; + }) + .catch((error) => { + console.error('Error fetching Form:', error); + }); + }); + } + + public async handleUploadComplete(res: IUploadResult[]) { + if (res.length === 0) { + return; + } + // As conversion is a 2-step process (xls file -> xml form -> enketo form) track progress + // so that uploaded file can be removed if not successful + let xformConversionSuccess = false; + this.uploading = true; + const [{ data, entry }] = res; + + const xform = await this.service.submitFormToConvertXlsToXForm(data as File); + + if (xform) { + const blob = new Blob([xform], { type: 'text/xml' }); + const xmlFile = new File([blob], 'form.xml', { type: 'text/xml' }); + const formData = new FormData(); + formData.append('files', xmlFile); + + const enketoContent = await this.service.submitFormToConvertXFormToEnketo(formData); + if (enketoContent) { + const { form, languageMap, model, theme } = enketoContent; + // Update db entry with form_xlsx + this.form = await this.service.updateFormById(this.form.id, { + form_xlsx: `${this.storageBucketName}/${this.storageFolderPath}/${entry.name}`, + enketo_form: form, + enketo_model: model, + enketo_definition: { ...(this.form.enketo_definition as any), languageMap, theme }, + }); + this.updateFeedbackMessage = 'Form updated successfully!'; + this.uploading = false; + xformConversionSuccess = true; + } + } + // If conversion not successful delete file from storage + if (!xformConversionSuccess) { + const storagePath = `${this.storageFolderPath}/${entry.name}`; + const { error } = await this.storageService.deleteFile(this.storageBucketName, storagePath); + if (error) throw error; + } + } +} diff --git a/apps/picsa-apps/dashboard/src/app/modules/monitoring/pages/view/view-monitoring-forms.component.html b/apps/picsa-apps/dashboard/src/app/modules/monitoring/pages/view/view-monitoring-forms.component.html index 00e7f4b91..b75f10c03 100644 --- a/apps/picsa-apps/dashboard/src/app/modules/monitoring/pages/view/view-monitoring-forms.component.html +++ b/apps/picsa-apps/dashboard/src/app/modules/monitoring/pages/view/view-monitoring-forms.component.html @@ -2,7 +2,10 @@

Monitoring Form View

@if(form){ - +
+ + +
}
@if(form){ diff --git a/apps/picsa-apps/dashboard/src/app/modules/monitoring/pages/view/view-monitoring-forms.component.scss b/apps/picsa-apps/dashboard/src/app/modules/monitoring/pages/view/view-monitoring-forms.component.scss index 5e710ebdf..ae8bc9dc2 100644 --- a/apps/picsa-apps/dashboard/src/app/modules/monitoring/pages/view/view-monitoring-forms.component.scss +++ b/apps/picsa-apps/dashboard/src/app/modules/monitoring/pages/view/view-monitoring-forms.component.scss @@ -1,24 +1,28 @@ -.form-content{ - display: flex; - flex-direction: column; - gap: 1.4rem; +.form-content { + display: flex; + flex-direction: column; + gap: 1.4rem; } -.submitButton{ - width: 7rem; - margin-bottom: 1rem; +.submitButton { + width: 7rem; + margin-bottom: 1rem; } -.form-data{ - display: flex; - flex-direction: column; - gap: 0.5rem; +.form-data { + display: flex; + flex-direction: column; + gap: 0.5rem; } -.data-container{ - margin-left: 2rem; - max-height: 25rem; - overflow-y: auto; +.data-container { + margin-left: 2rem; + max-height: 25rem; + overflow-y: auto; +} +label { + font-weight: 700; +} +.action-button-section { + display: flex; + flex-direction: row; + gap: 5px; } -label{ - font-weight: 700; - -} \ No newline at end of file diff --git a/libs/shared/src/services/core/supabase/components/upload/supabase-upload.component.ts b/libs/shared/src/services/core/supabase/components/upload/supabase-upload.component.ts index 25928c919..4044bec88 100644 --- a/libs/shared/src/services/core/supabase/components/upload/supabase-upload.component.ts +++ b/libs/shared/src/services/core/supabase/components/upload/supabase-upload.component.ts @@ -205,7 +205,7 @@ export class SupabaseUploadComponent { private async checkDuplicateUpload(file: UppyFile) { const storageFile = await this.storageService.getFile({ - bucketId: 'resources', + bucketId: this.storageBucketName, filename: file.name, folderPath: this.storageFolderPath || '', }); diff --git a/libs/shared/src/services/core/supabase/services/supabase-storage.service.ts b/libs/shared/src/services/core/supabase/services/supabase-storage.service.ts index aecc75557..7e8703b04 100644 --- a/libs/shared/src/services/core/supabase/services/supabase-storage.service.ts +++ b/libs/shared/src/services/core/supabase/services/supabase-storage.service.ts @@ -95,6 +95,10 @@ export class SupabaseStorageService { return data?.[0] || null; } + public async deleteFile(bucketId: string, filePath: string) { + return this.storage.from(bucketId).remove([filePath]); + } + /** Return the link to a file in a public bucket */ public getPublicLink(bucketId: string, objectPath: string) { return this.storage.from(bucketId).getPublicUrl(objectPath).data.publicUrl;