diff --git a/libs/shared/src/i18n/en.json b/libs/shared/src/i18n/en.json index 41f3844793..293bff7118 100644 --- a/libs/shared/src/i18n/en.json +++ b/libs/shared/src/i18n/en.json @@ -728,6 +728,25 @@ "display": { "submissionMessage": "The form has successfully been submitted." }, + "draftRecords": { + "buttonLoad": "Load draft record", + "confirmModal": { + "confirmDelete": "Do you really want to delete this draft record?", + "confirmLoad": "Do you really want to load this draft record?", + "delete": "Delete this draft", + "load": "Load this draft" + }, + "creationDate": "Creation date", + "delete": "Delete this draft", + "load": "Load this draft", + "noDrafts": "No draft available.", + "preview": "Preview this draft", + "previewTitle": "Draft record", + "save": "Save as draft", + "select": "Select draft record", + "successEdit": "Successfully edited draft", + "successSave": "Successfully saved as draft" + }, "layout": { "delete": { "confirmationMessage": "Do you confirm the deletion of the layout {{name}}?" diff --git a/libs/shared/src/i18n/fr.json b/libs/shared/src/i18n/fr.json index 32084e4d30..7d3bd81a99 100644 --- a/libs/shared/src/i18n/fr.json +++ b/libs/shared/src/i18n/fr.json @@ -742,6 +742,25 @@ "display": { "submissionMessage": "Le formulaire a été sauvegardé." }, + "draftRecords": { + "buttonLoad": "Charger un brouillon", + "confirmModal": { + "confirmDelete": "Voulez-vous vraiment supprimer ce brouillon ?", + "confirmLoad": "Voulez-vous vraiment charger ce brouillon ?", + "delete": "Supprimer ce brouillon", + "load": "Charger ce brouillon" + }, + "creationDate": "Date de création", + "delete": "Supprimer ce brouillon", + "load": "Charger ce brouillon", + "noDrafts": "Aucun enregistrement brouillon disponible", + "preview": "Prévisualiser ce brouillon", + "previewTitle": "Enregistrement brouillon", + "save": "Enregistrer comme brouillon", + "select": "Sélectionner le brouillon", + "successEdit": "Brouillon modifié avec succès", + "successSave": "Enregistré dans les brouillons avec succès" + }, "layout": { "delete": { "confirmationMessage": "Voulez-vous vraiment supprimer la mise en page {{name}} ?" diff --git a/libs/shared/src/i18n/test.json b/libs/shared/src/i18n/test.json index 468c07ccc3..d039cb81b1 100644 --- a/libs/shared/src/i18n/test.json +++ b/libs/shared/src/i18n/test.json @@ -728,6 +728,25 @@ "display": { "submissionMessage": "******" }, + "draftRecords": { + "buttonLoad": "******", + "confirmModal": { + "confirmDelete": "******", + "confirmLoad": "******", + "delete": "******", + "load": "******" + }, + "creationDate": "******", + "delete": "******", + "load": "******", + "noDrafts": "******", + "preview": "******", + "previewTitle": "******", + "save": "******", + "select": "******", + "successEdit": "******", + "successSave": "******" + }, "layout": { "delete": { "confirmationMessage": "****** {{name}} ******" diff --git a/libs/shared/src/lib/components/draft-record-list-modal/draft-record-list-modal.component.html b/libs/shared/src/lib/components/draft-record-list-modal/draft-record-list-modal.component.html new file mode 100644 index 0000000000..649fc37946 --- /dev/null +++ b/libs/shared/src/lib/components/draft-record-list-modal/draft-record-list-modal.component.html @@ -0,0 +1,100 @@ + + + + {{ 'components.form.draftRecords.select' | translate }} + + + + + + + + + + + + + + {{ 'components.form.draftRecords.creationDate' | translate }} + + + + {{ element.createdAt | sharedDate : 'short' }} + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ + 'common.close' | translate + }} + + + + + + + + + + + + + diff --git a/libs/shared/src/lib/components/draft-record-list-modal/draft-record-list-modal.component.scss b/libs/shared/src/lib/components/draft-record-list-modal/draft-record-list-modal.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/libs/shared/src/lib/components/draft-record-list-modal/draft-record-list-modal.component.spec.ts b/libs/shared/src/lib/components/draft-record-list-modal/draft-record-list-modal.component.spec.ts new file mode 100644 index 0000000000..8f0a4a5b62 --- /dev/null +++ b/libs/shared/src/lib/components/draft-record-list-modal/draft-record-list-modal.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { DraftRecordListModalComponent } from './draft-record-list-modal.component'; + +describe('DraftRecordListModalComponent', () => { + let component: DraftRecordListModalComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [DraftRecordListModalComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(DraftRecordListModalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/libs/shared/src/lib/components/draft-record-list-modal/draft-record-list-modal.component.ts b/libs/shared/src/lib/components/draft-record-list-modal/draft-record-list-modal.component.ts new file mode 100644 index 0000000000..2a010d99cb --- /dev/null +++ b/libs/shared/src/lib/components/draft-record-list-modal/draft-record-list-modal.component.ts @@ -0,0 +1,180 @@ +import { SkeletonTableModule } from '../skeleton/skeleton-table/skeleton-table.module'; +import { ConfirmService } from '../../services/confirm/confirm.service'; +import { DialogRef, DIALOG_DATA } from '@angular/cdk/dialog'; +import { DateModule } from '../../pipes/date/date.module'; +import { Component, OnInit, Inject } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { GET_DRAFT_RECORDS } from './graphql/queries'; +import { CommonModule } from '@angular/common'; +import { Dialog } from '@angular/cdk/dialog'; +import { Apollo } from 'apollo-angular'; +import { + DraftRecordsQueryResponse, + DraftRecord, +} from '../../models/draft-record.model'; +import { + TableModule, + DialogModule, + ButtonModule, + TooltipModule, +} from '@oort-front/ui'; +import { EmptyModule } from '../ui/empty/empty.module'; +import { Form, FormQueryResponse } from '../../models/form.model'; +import { FormHelpersService } from '../../services/form-helper/form-helper.service'; + +/** Dialog data interface */ +interface DialogData { + form: string; +} + +/** + * Display list of available drafts for form & user, in a modal. + */ +@Component({ + standalone: true, + imports: [ + CommonModule, + TableModule, + DateModule, + DialogModule, + ButtonModule, + SkeletonTableModule, + TooltipModule, + EmptyModule, + ], + selector: 'shared-draft-record-list-modal', + templateUrl: './draft-record-list-modal.component.html', + styleUrls: ['./draft-record-list-modal.component.scss'], +}) +export class DraftRecordListModalComponent implements OnInit { + /** Array of available draft records */ + public draftRecords: Array = new Array(); + /** Displayed table columns */ + public displayedColumns = ['createdAt', 'actions']; + /** Displayed skeleton table columns */ + public displayedColumnsForSkeleton = ['createdAt']; + /** Loading indicator */ + public loading = true; + /** Current form */ + private form!: Form; + + /** @returns True if the draft records table is empty */ + get empty(): boolean { + return !this.loading && this.draftRecords.length === 0; + } + + /** + * Display list of available drafts for form & user, in a modal. + * + * @param confirmService Shared confirm service modal + * @param translate Angular translate service + * @param apollo Apollo service + * @param dialog CDK Dialog service + * @param dialogRef Dialog reference + * @param formHelpersService This is the service that will handle forms. + * @param data Data passed to the dialog, here the formId of the current form + */ + constructor( + private confirmService: ConfirmService, + private translate: TranslateService, + private apollo: Apollo, + public dialog: Dialog, + public dialogRef: DialogRef, + public formHelpersService: FormHelpersService, + @Inject(DIALOG_DATA) + public data: DialogData + ) {} + + ngOnInit(): void { + this.fetchDraftRecords(); + } + + /** + * Fetches all the draft records associated to the current form + */ + fetchDraftRecords() { + this.apollo + .query({ + query: GET_DRAFT_RECORDS, + variables: { + form: this.data.form, + }, + }) + .pipe() + .subscribe(({ data }) => { + this.form = data.form; + this.draftRecords = data.draftRecords; + this.loading = false; + }); + } + + /** + * Opens an existing draft record on modal + * + * @param element draft record selected + */ + async onPreview(element: DraftRecord) { + const { DraftRecordModalComponent } = await import( + '../draft-record-modal/draft-record-modal.component' + ); + this.dialog.open(DraftRecordModalComponent, { + data: { + form: this.form, + data: element.data, + }, + }); + } + + /** + * Handles the deletion of a specific draft record + * + * @param element Draft record to delete + */ + onDelete(element: DraftRecord) { + const dialogRef = this.confirmService.openConfirmModal({ + title: this.translate.instant( + 'components.form.draftRecords.confirmModal.delete' + ), + content: this.translate.instant( + 'components.form.draftRecords.confirmModal.confirmDelete' + ), + confirmText: this.translate.instant('components.confirmModal.confirm'), + confirmVariant: 'danger', + }); + dialogRef.closed.pipe().subscribe((value) => { + if (value) { + this.loading = true; + const callback = () => { + this.fetchDraftRecords(); + }; + this.formHelpersService.deleteRecordDraft( + element.id as string, + callback + ); + } + }); + } + + /** + * Closes the modal + * + * @param element Draft record to be returned to form component + */ + onClose(element: DraftRecord): void { + const confirmDialogRef = this.confirmService.openConfirmModal({ + title: this.translate.instant( + 'components.form.draftRecords.confirmModal.load' + ), + content: this.translate.instant( + 'components.form.draftRecords.confirmModal.confirmLoad' + ), + confirmText: this.translate.instant('components.confirmModal.confirm'), + confirmVariant: 'primary', + }); + confirmDialogRef.closed.pipe().subscribe((value) => { + if (value) { + this.dialogRef.close(element as any); + } + }); + } +} diff --git a/libs/shared/src/lib/components/draft-record-list-modal/graphql/queries.ts b/libs/shared/src/lib/components/draft-record-list-modal/graphql/queries.ts new file mode 100644 index 0000000000..7638caa351 --- /dev/null +++ b/libs/shared/src/lib/components/draft-record-list-modal/graphql/queries.ts @@ -0,0 +1,22 @@ +import { gql } from 'apollo-angular'; + +/** Graphql request for getting draft records */ +export const GET_DRAFT_RECORDS = gql` + query GetDraftRecords($form: ID!) { + draftRecords(form: $form) { + id + createdAt + data + } + form(id: $form) { + id + structure + metadata { + name + automated + canSee + canUpdate + } + } + } +`; diff --git a/libs/shared/src/lib/components/draft-record-modal/draft-record-modal.component.html b/libs/shared/src/lib/components/draft-record-modal/draft-record-modal.component.html new file mode 100644 index 0000000000..df6f2cc9b4 --- /dev/null +++ b/libs/shared/src/lib/components/draft-record-modal/draft-record-modal.component.html @@ -0,0 +1,64 @@ + + + + {{ 'components.form.draftRecords.previewTitle' | translate }} + + + + + {{ page.title ? page.title : page.name }} + + + + + + + + + + + + + + + 1" + (click)="survey && survey.prevPage()" + cdkFocusInitial + [disabled]="!survey || survey.isFirstPage" + > + {{ 'common.previous' | translate }} + + 1" + (click)="survey && survey.nextPage()" + cdkFocusInitial + [disabled]="!survey || survey.isLastPage" + > + {{ 'common.next' | translate }} + + + {{ + 'common.close' | translate + }} + + + + + + + + + diff --git a/libs/shared/src/lib/components/draft-record-modal/draft-record-modal.component.scss b/libs/shared/src/lib/components/draft-record-modal/draft-record-modal.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/libs/shared/src/lib/components/draft-record-modal/draft-record-modal.component.spec.ts b/libs/shared/src/lib/components/draft-record-modal/draft-record-modal.component.spec.ts new file mode 100644 index 0000000000..a0faf173b0 --- /dev/null +++ b/libs/shared/src/lib/components/draft-record-modal/draft-record-modal.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { DraftRecordModalComponent } from './draft-record-modal.component'; + +describe('DraftRecordModalComponent', () => { + let component: DraftRecordModalComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [DraftRecordModalComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(DraftRecordModalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/libs/shared/src/lib/components/draft-record-modal/draft-record-modal.component.ts b/libs/shared/src/lib/components/draft-record-modal/draft-record-modal.component.ts new file mode 100644 index 0000000000..b4c74c7bf3 --- /dev/null +++ b/libs/shared/src/lib/components/draft-record-modal/draft-record-modal.component.ts @@ -0,0 +1,82 @@ +import { FormBuilderService } from '../../services/form-builder/form-builder.service'; +import { DIALOG_DATA } from '@angular/cdk/dialog'; +import { + ButtonModule, + DialogModule, + IconModule, + SpinnerModule, + TabsModule, +} from '@oort-front/ui'; +import { Component, OnInit, Inject } from '@angular/core'; +import { SurveyModule } from 'survey-angular-ui'; +import { CommonModule } from '@angular/common'; +import { SurveyModel } from 'survey-core'; +import { Form } from '../../models/form.model'; +import { BehaviorSubject } from 'rxjs'; + +/** Dialog data interface */ +interface DialogData { + form: Form; + data: any; +} + +/** + * Display a draft record in a modal. + */ +@Component({ + standalone: true, + imports: [ + CommonModule, + DialogModule, + SurveyModule, + SpinnerModule, + ButtonModule, + TabsModule, + IconModule, + ], + selector: 'shared-draft-record-modal', + templateUrl: './draft-record-modal.component.html', + styleUrls: ['../../style/survey.scss', './draft-record-modal.component.scss'], +}) +export class DraftRecordModalComponent implements OnInit { + /** Loading indicator */ + public loading = true; + /** Survey instance */ + public survey!: SurveyModel; + /** Selected page index */ + public selectedPageIndex: BehaviorSubject = + new BehaviorSubject(0); + /** Selected page index as observable */ + public selectedPageIndex$ = this.selectedPageIndex.asObservable(); + + /** + * Display a draft record in a modal. + * + * @param formBuilderService Form builder service + * @param data Data passed to the dialog + */ + constructor( + private formBuilderService: FormBuilderService, + @Inject(DIALOG_DATA) public data: DialogData + ) {} + + ngOnInit(): void { + this.survey = this.formBuilderService.createSurvey( + this.data?.form?.structure || '', + this.data?.form?.metadata + ); + this.survey.data = this.data.data; + this.loading = false; + } + + /** + * Handles the show page event + * + * @param i The index of the page + */ + public onShowPage(i: number): void { + if (this.survey) { + this.survey.currentPageNo = i; + } + } +} diff --git a/libs/shared/src/lib/components/draft-record/draft-record.component.html b/libs/shared/src/lib/components/draft-record/draft-record.component.html new file mode 100644 index 0000000000..9f9c979d0e --- /dev/null +++ b/libs/shared/src/lib/components/draft-record/draft-record.component.html @@ -0,0 +1,6 @@ + diff --git a/libs/shared/src/lib/components/draft-record/draft-record.component.scss b/libs/shared/src/lib/components/draft-record/draft-record.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/libs/shared/src/lib/components/draft-record/draft-record.component.spec.ts b/libs/shared/src/lib/components/draft-record/draft-record.component.spec.ts new file mode 100644 index 0000000000..0f077b9787 --- /dev/null +++ b/libs/shared/src/lib/components/draft-record/draft-record.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { DraftRecordComponent } from './draft-record.component'; + +describe('DraftRecordComponent', () => { + let component: DraftRecordComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [DraftRecordComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(DraftRecordComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/libs/shared/src/lib/components/draft-record/draft-record.component.ts b/libs/shared/src/lib/components/draft-record/draft-record.component.ts new file mode 100644 index 0000000000..92df8dda19 --- /dev/null +++ b/libs/shared/src/lib/components/draft-record/draft-record.component.ts @@ -0,0 +1,57 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ButtonModule, TooltipModule } from '@oort-front/ui'; +import { Dialog } from '@angular/cdk/dialog'; +import { SurveyModel } from 'survey-core'; +import { UnsubscribeComponent } from '../utils/unsubscribe/unsubscribe.component'; +import { takeUntil } from 'rxjs'; +import { TranslateModule } from '@ngx-translate/core'; + +/** + * Shared button to open list of available record drafts. + */ +@Component({ + selector: 'shared-draft-record', + standalone: true, + imports: [CommonModule, ButtonModule, TooltipModule, TranslateModule], + templateUrl: './draft-record.component.html', + styleUrls: ['./draft-record.component.scss'], +}) +export class DraftRecordComponent extends UnsubscribeComponent { + /** Survey model */ + @Input() survey!: SurveyModel; + /** Form input */ + @Input() formId!: string; + /** Emit event when selecting draft */ + @Output() loadDraft: EventEmitter = new EventEmitter(); + + /** + * Shared button to open list of available record drafts. + * + * @param dialog This is the Angular Dialog service. + */ + constructor(public dialog: Dialog) { + super(); + } + + /** + * Open draft list. + */ + public async onOpenDrafts(): Promise { + // Lazy load modal + const { DraftRecordListModalComponent } = await import( + '../draft-record-list-modal/draft-record-list-modal.component' + ); + const dialogRef = this.dialog.open(DraftRecordListModalComponent, { + data: { + form: this.formId, + }, + }); + dialogRef.closed.pipe(takeUntil(this.destroy$)).subscribe((value: any) => { + if (value) { + this.survey.data = value.data; + this.loadDraft.emit(value.id); + } + }); + } +} diff --git a/libs/shared/src/lib/components/form-modal/form-modal.component.html b/libs/shared/src/lib/components/form-modal/form-modal.component.html index ccdf818b58..f014384f99 100644 --- a/libs/shared/src/lib/components/form-modal/form-modal.component.html +++ b/libs/shared/src/lib/components/form-modal/form-modal.component.html @@ -11,6 +11,11 @@ *ngIf="survey" [survey]="survey" > + + {{ + 'components.form.draftRecords.save' | translate + }} = new BehaviorSubject(0); /** Selected page index as observable */ public selectedPageIndex$ = this.selectedPageIndex.asObservable(); + /** The id of the last draft record that was loaded */ + public lastDraftRecord?: string; + /** Disables the save as draft button */ + public disableSaveAsDraft = false; /** Available pages*/ private pages = new BehaviorSubject([]); /** Pages as observable */ public pages$ = this.pages.asObservable(); + /** Is multi edition of records enabled ( for grid actions ) */ + protected isMultiEdition = false; + /** Temporary storage of files */ + protected temporaryFilesStorage: any = {}; + /** Stored merged data */ + private storedMergedData: any; /** - * The constructor function is a special function that is called when a new instance of the class is - * created. + * Display a form instance in a modal. * * @param data This is the data that is passed to the modal when it is opened. * @param dialog This is the Angular Dialog service. @@ -233,6 +242,10 @@ export class FormModalComponent this.survey.getQuestionByName(field.name).readOnly = true; }); } + this.survey.onValueChanged.add(() => { + // Allow user to save as draft + this.disableSaveAsDraft = false; + }); this.survey.onComplete.add(this.onComplete); if (this.storedMergedData) { this.survey.data = { @@ -322,7 +335,7 @@ export class FormModalComponent this.form?.id ); // await Promise.allSettled(promises); - await this.formHelpersService.createCachedRecords(survey); + await this.formHelpersService.createTemporaryRecords(survey); if (this.data.recordId) { if (this.isMultiEdition) { @@ -349,6 +362,15 @@ export class FormModalComponent this.dialogRef.close(); }); } else { + if (this.lastDraftRecord) { + const callback = () => { + this.lastDraftRecord = undefined; + }; + this.formHelpersService.deleteRecordDraft( + this.lastDraftRecord, + callback + ); + } this.ngZone.run(() => { this.dialogRef.close({ template: this.data.template, @@ -410,6 +432,15 @@ export class FormModalComponent }) .subscribe({ next: ({ errors, data }) => { + if (this.lastDraftRecord) { + const callback = () => { + this.lastDraftRecord = undefined; + }; + this.formHelpersService.deleteRecordDraft( + this.lastDraftRecord, + callback + ); + } this.handleRecordMutationResponse({ data, errors }, 'editRecords'); }, error: (err) => { @@ -606,12 +637,36 @@ export class FormModalComponent }); } + /** + * Saves the current data as a draft record + */ + public saveAsDraft(): void { + const callback = (details: any) => { + this.lastDraftRecord = details.id; + }; + this.formHelpersService.saveAsDraft( + this.survey, + this.form?.id as string, + this.lastDraftRecord, + callback + ); + } + + /** + * Handle draft record load . + * + * @param id if of the draft record loaded + */ + public onLoadDraftRecord(id: string): void { + this.lastDraftRecord = id; + this.disableSaveAsDraft = true; + } + /** * Clears the cache for the records created by resource questions */ override ngOnDestroy(): void { super.ngOnDestroy(); - this.formHelpersService.cleanCachedRecords(this.survey); this.survey?.dispose(); } } diff --git a/libs/shared/src/lib/components/form/form.component.html b/libs/shared/src/lib/components/form/form.component.html index 54d6ea791f..d8eae2eec8 100644 --- a/libs/shared/src/lib/components/form/form.component.html +++ b/libs/shared/src/lib/components/form/form.component.html @@ -1,15 +1,19 @@ + {{ 'common.next' | translate }} + {{ + 'components.form.draftRecords.save' | translate + }} {{ 'common.save' | translate }} diff --git a/libs/shared/src/lib/components/form/form.component.ts b/libs/shared/src/lib/components/form/form.component.ts index fe4f87f64a..e5c8dd5021 100644 --- a/libs/shared/src/lib/components/form/form.component.ts +++ b/libs/shared/src/lib/components/form/form.component.ts @@ -27,7 +27,7 @@ import { TranslateService } from '@ngx-translate/core'; import { UnsubscribeComponent } from '../utils/unsubscribe/unsubscribe.component'; import { FormHelpersService } from '../../services/form-helper/form-helper.service'; import { SnackbarService, UILayoutService } from '@oort-front/ui'; -import { cloneDeep, isNil } from 'lodash'; +import { isNil } from 'lodash'; /** * This component is used to display forms @@ -60,10 +60,6 @@ export class FormComponent @ViewChild('formContainer') formContainer!: ElementRef; /** Date when the form was last modified */ public modifiedAt: Date | null = null; - /** ID for local storage */ - private storageId = ''; - /** Date of local storage */ - public storageDate?: Date; /** indicates whether the data is from the cache */ public isFromCacheData = false; /** Selected page index */ @@ -75,6 +71,15 @@ export class FormComponent private pages = new BehaviorSubject([]); /** Pages as observable */ public pages$ = this.pages.asObservable(); + /** The id of the last draft record that was loaded */ + public lastDraftRecord?: string; + /** Disables the save as draft button */ + public disableSaveAsDraft = false; + /** As we save the draft record in the db, the local storage is no longer used */ + /** ID for local storage */ + // private storageId = ''; + /** Date of local storage */ + // public storageDate?: Date; /** * The constructor function is a special function that is called when a new instance of the class is @@ -96,7 +101,7 @@ export class FormComponent private authService: AuthService, private layoutService: UILayoutService, private formBuilderService: FormBuilderService, - private formHelpersService: FormHelpersService, + public formHelpersService: FormHelpersService, private translate: TranslateService ) { super(); @@ -130,7 +135,10 @@ export class FormComponent if (!this.record && !this.form.canCreateRecords) { this.survey.mode = 'display'; } - this.survey.onValueChanged.add(this.valueChange.bind(this)); + this.survey.onValueChanged.add(() => { + // Allow user to save as draft + this.disableSaveAsDraft = false; + }); this.survey.onComplete.add(this.onComplete); // Unset readOnly fields if it's the record creation @@ -142,25 +150,26 @@ export class FormComponent }); } // Fetch cached data from local storage - this.storageId = `record:${this.record ? 'update' : ''}:${this.form.id}`; - const storedData = localStorage.getItem(this.storageId); - const cachedData = storedData ? JSON.parse(storedData).data : null; - this.storageDate = storedData - ? new Date(JSON.parse(storedData).date) - : undefined; - this.isFromCacheData = !!cachedData; - if (this.isFromCacheData) { - this.snackBar.openSnackBar( - this.translate.instant('common.notifications.loadedFromCache', { - type: this.translate.instant('common.record.one'), - }) - ); - } + //this.storageId = `record:${this.record ? 'update' : ''}:${this.form.id}`; + //const storedData = localStorage.getItem(this.storageId); + //const cachedData = storedData ? JSON.parse(storedData).data : null; + //this.storageDate = storedData + //? new Date(JSON.parse(storedData).date) + //: undefined; + // this.isFromCacheData = !!cachedData; + //if (this.isFromCacheData) { + //this.snackBar.openSnackBar( + //this.translate.instant('common.notifications.loadedFromCache', { + //type: this.translate.instant('common.record.one'), + //}) + //); + //} - if (cachedData) { - this.survey.data = cachedData; - // this.setUserVariables(); - } else if (this.form.uniqueRecord && this.form.uniqueRecord.data) { + //if (cachedData) { + //this.survey.data = cachedData; + // this.setUserVariables(); + //} + if (this.form.uniqueRecord && this.form.uniqueRecord.data) { this.survey.data = this.form.uniqueRecord.data; this.modifiedAt = this.form.uniqueRecord.modifiedAt || null; } else if (this.record && this.record.data) { @@ -205,26 +214,6 @@ export class FormComponent setTimeout(() => (this.surveyActive = true), 100); } - /** - * Handles the value change event when the user completes the survey - */ - public valueChange(): void { - // Cache the survey data, but remove the files from it - // to avoid hitting the local storage limit - const data = cloneDeep(this.survey.data); - Object.keys(data).forEach((key) => { - const question = this.survey.getQuestionByName(key); - if (question && question.getType() === 'file') { - delete data[key]; - } - }); - - localStorage.setItem( - this.storageId, - JSON.stringify({ data, date: new Date() }) - ); - } - /** * Calls the complete method of the survey if no error. */ @@ -239,6 +228,24 @@ export class FormComponent } } + /** + * Saves the current data as a draft record + */ + public saveAsDraft(): void { + const callback = (details: any) => { + this.surveyActive = true; + this.lastDraftRecord = details.id; + // Updates parent component + this.save.emit(details.save); + }; + this.formHelpersService.saveAsDraft( + this.survey, + this.form.id as string, + this.lastDraftRecord, + callback + ); + } + /** * Creates the record when it is complete, or update it if provided. */ @@ -255,9 +262,7 @@ export class FormComponent ); this.formHelpersService.setEmptyQuestions(this.survey); // We wait for the resources questions to update their ids - // await Promise.allSettled(promises); - await this.formHelpersService.createCachedRecords(this.survey); - // this.survey.data = surveyData; + await this.formHelpersService.createTemporaryRecords(this.survey); // If is an already saved record, edit it if (this.record || this.form.uniqueRecord) { const recordId = this.record @@ -289,7 +294,16 @@ export class FormComponent this.surveyActive = true; this.snackBar.openSnackBar(errors[0].message, { error: true }); } else { - localStorage.removeItem(this.storageId); + if (this.lastDraftRecord) { + const callback = () => { + this.lastDraftRecord = undefined; + }; + this.formHelpersService.deleteRecordDraft( + this.lastDraftRecord, + callback + ); + } + // localStorage.removeItem(this.storageId); if (data.editRecord || data.addRecord.form.uniqueRecord) { this.survey.clear(false, false); if (data.addRecord) { @@ -335,10 +349,6 @@ export class FormComponent this.formHelpersService.clearTemporaryFilesStorage( this.temporaryFilesStorage ); - localStorage.removeItem(this.storageId); - this.formHelpersService.cleanCachedRecords(this.survey); - this.isFromCacheData = false; - this.storageDate = undefined; } /** @@ -357,6 +367,16 @@ export class FormComponent } } + /** + * Handle draft record load . + * + * @param id if of the draft record loaded + */ + public onLoadDraftRecord(id: string): void { + this.lastDraftRecord = id; + this.disableSaveAsDraft = true; + } + /** * Open a dialog modal to confirm the recovery of data * @@ -402,8 +422,6 @@ export class FormComponent /** It removes the item from local storage, clears cached records, and discards the search. */ override ngOnDestroy(): void { super.ngOnDestroy(); - localStorage.removeItem(this.storageId); - this.formHelpersService.cleanCachedRecords(this.survey); this.survey?.dispose(); } } diff --git a/libs/shared/src/lib/components/form/form.module.ts b/libs/shared/src/lib/components/form/form.module.ts index 166787768f..0f750dd1cd 100644 --- a/libs/shared/src/lib/components/form/form.module.ts +++ b/libs/shared/src/lib/components/form/form.module.ts @@ -7,6 +7,7 @@ import { FormActionsModule } from '../form-actions/form-actions.module'; import { RecordSummaryModule } from '../record-summary/record-summary.module'; import { ButtonModule } from '@oort-front/ui'; import { SurveyModule } from 'survey-angular-ui'; +import { DraftRecordComponent } from '../draft-record/draft-record.component'; /** * FormModule is a class used to manage all the modules and components @@ -25,6 +26,7 @@ import { SurveyModule } from 'survey-angular-ui'; SurveyModule, SurveyModule, FixedWrapperModule, + DraftRecordComponent, ], exports: [FormComponent], }) diff --git a/libs/shared/src/lib/components/form/graphql/mutations.ts b/libs/shared/src/lib/components/form/graphql/mutations.ts index deddc78e41..dbf765592c 100644 --- a/libs/shared/src/lib/components/form/graphql/mutations.ts +++ b/libs/shared/src/lib/components/form/graphql/mutations.ts @@ -1,7 +1,5 @@ import { gql } from 'apollo-angular'; -// === ADD RECORD === - /** Graphql request for adding a new record to a form */ export const ADD_RECORD = gql` mutation addRecord($form: ID!, $data: JSON!, $display: Boolean) { @@ -27,8 +25,6 @@ export const ADD_RECORD = gql` } `; -// === EDIT RECORD === - /** Graphql request for editing a record by its id */ export const EDIT_RECORD = gql` mutation editRecord( diff --git a/libs/shared/src/lib/components/record-summary/record-summary.component.ts b/libs/shared/src/lib/components/record-summary/record-summary.component.ts index ca4c38f7ff..a0508b222d 100644 --- a/libs/shared/src/lib/components/record-summary/record-summary.component.ts +++ b/libs/shared/src/lib/components/record-summary/record-summary.component.ts @@ -2,7 +2,7 @@ import { Component, EventEmitter, Input, Output } from '@angular/core'; import { Record } from '../../models/record.model'; /** - * This component is used to show a summary of a record and its informations + * Show a summary of a record and its information */ @Component({ selector: 'shared-record-summary', diff --git a/libs/shared/src/lib/components/resource-modal/resource-modal.component.ts b/libs/shared/src/lib/components/resource-modal/resource-modal.component.ts index 06a7f4aed8..dab3be3d83 100644 --- a/libs/shared/src/lib/components/resource-modal/resource-modal.component.ts +++ b/libs/shared/src/lib/components/resource-modal/resource-modal.component.ts @@ -1,7 +1,7 @@ import { Component } from '@angular/core'; import { FormModalComponent } from '../form-modal/form-modal.component'; -import { v4 as uuidv4 } from 'uuid'; -import localForage from 'localforage'; +// import { v4 as uuidv4 } from 'uuid'; +// import localForage from 'localforage'; import { BlockScrollStrategy, Overlay } from '@angular/cdk/overlay'; import { CommonModule } from '@angular/common'; import { RecordSummaryModule } from '../record-summary/record-summary.module'; @@ -15,6 +15,7 @@ import { TabsModule, } from '@oort-front/ui'; import { SurveyModule } from 'survey-angular-ui'; +import { DraftRecordComponent } from '../draft-record/draft-record.component'; /** * Factory for creating scroll strategy @@ -46,6 +47,7 @@ export function scrollFactory(overlay: Overlay): () => BlockScrollStrategy { SpinnerModule, TabsModule, SurveyModule, + DraftRecordComponent, ], }) export class ResourceModalComponent extends FormModalComponent { @@ -67,26 +69,41 @@ export class ResourceModalComponent extends FormModalComponent { this.updateData(this.data.recordId, survey); } } else { - await this.formHelpersService.uploadFiles( - survey, - this.temporaryFilesStorage, - this.form?.id + const callback = (details: any) => { + this.ngZone.run(() => { + this.dialogRef.close({ + template: this.data.template, + data: { + id: details.id, + data: survey.data, + }, + } as any); + }); + }; + + this.formHelpersService.saveAsDraft( + this.survey, + this.form?.id as string, + this.lastDraftRecord, + callback ); - const temporaryId = uuidv4(); - await localForage.setItem( - temporaryId.toString(), - JSON.stringify({ data: survey.data, template: this.data.template }) - ); //We save the question temporarily before applying the mutation. - this.ngZone.run(() => { - this.dialogRef.close({ - template: this.data.template, - data: { - id: temporaryId, - data: survey.data, - }, - } as any); - }); + + // Temporary record saved in local storage (outdated) + // const temporaryId = uuidv4(); + // await localForage.setItem( + // temporaryId.toString(), + // JSON.stringify({ data: survey.data, template: this.data.template }) + // ); //We save the question temporarily before applying the mutation. + // this.ngZone.run(() => { + // this.dialogRef.close({ + // template: this.data.template, + // data: { + // id: temporaryId, + // data: survey.data, + // }, + // } as any); + // }); } - survey.showCompletedPage = true; + // survey.showCompletedPage = true; } } diff --git a/libs/shared/src/lib/models/draft-record.model.ts b/libs/shared/src/lib/models/draft-record.model.ts new file mode 100644 index 0000000000..7cd6a04e98 --- /dev/null +++ b/libs/shared/src/lib/models/draft-record.model.ts @@ -0,0 +1,31 @@ +import { Form } from './form.model'; +import { User } from './user.model'; + +/** Model for version attributes. */ +interface Version { + id?: string; + createdAt?: Date; + data?: string; + createdBy?: User; +} + +/** Model for Draft Record object. */ +export interface DraftRecord { + id?: string; + incrementalId?: string; + createdAt?: Date; + modifiedAt?: Date; + deleted?: boolean; + data?: any; + form?: Form; + versions?: Version[]; + createdBy?: User; + modifiedBy?: User; + canUpdate?: boolean; + canDelete?: boolean; +} + +/** Model for draft records graphql query response */ +export interface DraftRecordsQueryResponse { + draftRecords: DraftRecord[]; +} diff --git a/libs/shared/src/lib/models/record.model.ts b/libs/shared/src/lib/models/record.model.ts index 2cdd9186a8..7150619059 100644 --- a/libs/shared/src/lib/models/record.model.ts +++ b/libs/shared/src/lib/models/record.model.ts @@ -1,3 +1,4 @@ +import { DraftRecord } from './draft-record.model'; import { Form } from './form.model'; import { User } from './user.model'; @@ -36,11 +37,21 @@ export interface AddRecordMutationResponse { addRecord: Record; } +/** Model for add draft record graphql mutation response */ +export interface AddDraftRecordMutationResponse { + addDraftRecord: Record; +} + /** Model for edit record graphql mutation response */ export interface EditRecordMutationResponse { editRecord: Record; } +/** Model for edit draft record graphql mutation response */ +export interface EditDraftRecordMutationResponse { + editDraftRecord: DraftRecord; +} + /** Model for delete record graphql mutation response */ export interface DeleteRecordMutationResponse { deleteRecord: Record; diff --git a/libs/shared/src/lib/services/form-helper/form-helper.service.ts b/libs/shared/src/lib/services/form-helper/form-helper.service.ts index bc8f59231a..11550c0750 100644 --- a/libs/shared/src/lib/services/form-helper/form-helper.service.ts +++ b/libs/shared/src/lib/services/form-helper/form-helper.service.ts @@ -11,9 +11,17 @@ import localForage from 'localforage'; import { snakeCase, cloneDeep, set } from 'lodash'; import { AuthService } from '../auth/auth.service'; import { BlobType, DownloadService } from '../download/download.service'; -import { AddRecordMutationResponse } from '../../models/record.model'; +import { + AddDraftRecordMutationResponse, + AddRecordMutationResponse, + EditDraftRecordMutationResponse, +} from '../../models/record.model'; import { Question } from '../../survey/types'; - +import { + ADD_DRAFT_RECORD, + DELETE_DRAFT_RECORD, + EDIT_DRAFT_RECORD, +} from './graphql/mutations'; /** * Shared survey helper service. */ @@ -225,35 +233,14 @@ export class FormHelpersService { } /** - * Clean cached records from passed survey. - * - * @param survey Survey from which we need to clean cached records. - */ - cleanCachedRecords(survey: SurveyModel): void { - if (!survey) return; - survey.getAllQuestions().forEach((question) => { - if (question.value) { - const type = question.getType(); - if (type === 'resources') { - question.value.forEach((recordId: string) => - localForage.removeItem(recordId) - ); - } else if (type === 'resource') { - localForage.removeItem(question.value); - } - } - }); - } - - /** - * Create cache records (from resource/s questions) of passed survey. + * Create temporary records (from resource/s questions) of passed survey. * * @param survey Survey to get questions from */ - public async createCachedRecords(survey: SurveyModel): Promise { + public async createTemporaryRecords(survey: SurveyModel): Promise { const promises: Promise[] = []; const questions = survey.getAllQuestions(); - const nestedRecordsToAdd: string[] = []; + const nestedRecordsToAdd: { draftIds: []; question: Question }[] = []; // Callbacks to update the ids of new records const updateIds: { @@ -261,18 +248,19 @@ export class FormHelpersService { } = {}; // Get all nested records to add - questions.forEach((question) => { + questions.forEach((question: Question) => { const type = question.getType(); - if (!['resource', 'resources'].includes(type)) return; - const uuidv4Pattern = - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; - + if (!['resource', 'resources'].includes(type) || !question.draftData) { + return; + } const isResource = type === 'resource'; - const toAdd = (isResource ? [question.value] : question.value).filter( - (x: string) => uuidv4Pattern.test(x) + (id: string) => id in question.draftData ); - nestedRecordsToAdd.push(...toAdd); + nestedRecordsToAdd.push({ + draftIds: toAdd, + question, + }); toAdd.forEach((id: string) => { updateIds[id] = (newId: string) => { @@ -283,31 +271,46 @@ export class FormHelpersService { }); }); - for (const localID of nestedRecordsToAdd) { - // load them from localForage and add them to the promises - const cache = await localForage.getItem(localID); - if (!cache) continue; - - const { template, data } = JSON.parse(cache as string); + for (const element of nestedRecordsToAdd) { + for (const draftId of element.draftIds) { + const data = element.question.draftData[draftId]; + const template = element.question.template; - promises.push( - firstValueFrom( - this.apollo.mutate({ - mutation: ADD_RECORD, - variables: { - form: template, - data, - }, + promises.push( + firstValueFrom( + this.apollo.mutate({ + mutation: ADD_RECORD, + variables: { + form: template, + data, + }, + }) + ).then((res) => { + // change the draftId to the new recordId + const newId = res.data?.addRecord?.id; + if (!newId) return; + updateIds[draftId](newId); + // update question.newCreatedRecords too + const isResource = element.question.getType() === 'resource'; + const draftIndex = ( + isResource + ? [element.question.newCreatedRecords] + : element.question.newCreatedRecords + ).indexOf(draftId); + if (draftIndex !== -1) { + if (isResource) { + element.question.newCreatedRecords = newId; + } else { + element.question.newCreatedRecords[draftIndex] = newId; + } + } + // delete old temporary/draft record and data + this.deleteRecordDraft(draftId); + delete element.question.draftData[draftId]; + return; }) - ).then((res) => { - // change the localID to the new recordId - const newId = res.data?.addRecord?.id; - if (!newId) return; - updateIds[localID](newId); - localForage.removeItem(localID); - return; - }) - ); + ); + } } await Promise.all(promises); @@ -433,6 +436,118 @@ export class FormHelpersService { return true; } + /** + * Saves the current data as a draft record + * + * @param survey Survey where to add the callbacks + * @param formId Form id of the survey + * @param draftId Draft record id + * @param callback callback method + */ + public saveAsDraft( + survey: SurveyModel, + formId: string, + draftId?: string, + callback?: any + ): void { + // Check if a draft has already been loaded + if (!draftId) { + // Add a new draft record to the database + const mutation = this.apollo.mutate({ + mutation: ADD_DRAFT_RECORD, + variables: { + form: formId, + data: survey.data, + }, + }); + mutation.subscribe({ + next: ({ errors, data }) => { + if (errors) { + survey.clear(false, true); + this.snackBar.openSnackBar(errors[0].message, { error: true }); + } else { + // localStorage.removeItem(this.storageId); + this.snackBar.openSnackBar( + this.translate.instant( + 'components.form.draftRecords.successSave' + ), + { + error: false, + } + ); + } + // Callback to emit save but stay in record addition mode + if (callback) { + callback({ + id: data?.addDraftRecord.id, + save: { + completed: false, + hideNewRecord: true, + }, + }); + } + }, + error: (err) => { + this.snackBar.openSnackBar(err.message, { error: true }); + }, + }); + } else { + // Edit last added draft record in the database + const mutation = this.apollo.mutate({ + mutation: EDIT_DRAFT_RECORD, + variables: { + id: draftId, + data: survey.data, + }, + }); + mutation.subscribe(({ errors }: any) => { + if (errors) { + survey.clear(false, true); + this.snackBar.openSnackBar(errors[0].message, { error: true }); + } else { + // localStorage.removeItem(this.storageId); + this.snackBar.openSnackBar( + this.translate.instant('components.form.draftRecords.successEdit'), + { + error: false, + } + ); + } + // Callback to emit save but stay in record addition mode + if (callback) { + callback({ + id: draftId, + save: { + completed: false, + hideNewRecord: true, + }, + }); + } + }); + } + } + + /** + * Handles the deletion of a specific draft record + * + * @param draftId Id of the draft record to delete + * @param callback callback method + */ + public deleteRecordDraft(draftId: string, callback?: any): void { + this.apollo + .mutate({ + mutation: DELETE_DRAFT_RECORD, + variables: { + id: draftId, + }, + }) + .subscribe(() => { + if (callback) { + callback(); + } + }); + } + /** * Checks if a string is already in snake case * diff --git a/libs/shared/src/lib/services/form-helper/graphql/mutations.ts b/libs/shared/src/lib/services/form-helper/graphql/mutations.ts new file mode 100644 index 0000000000..dc2252ec26 --- /dev/null +++ b/libs/shared/src/lib/services/form-helper/graphql/mutations.ts @@ -0,0 +1,49 @@ +import { gql } from 'apollo-angular'; + +/** Graphql request for adding a new draft record to a form */ +export const ADD_DRAFT_RECORD = gql` + mutation addDraftRecord($form: ID!, $data: JSON!, $display: Boolean) { + addDraftRecord(form: $form, data: $data) { + id + createdAt + modifiedAt + createdBy { + name + } + data(display: $display) + form { + uniqueRecord { + id + modifiedAt + createdBy { + name + } + data + } + } + } + } +`; + +/** Graphql request for editing a draft record by its id */ +export const EDIT_DRAFT_RECORD = gql` + mutation editDraftRecord($id: ID!, $data: JSON) { + editDraftRecord(id: $id, data: $data) { + id + data + createdAt + createdBy { + name + } + } + } +`; + +/** Delete draft record gql mutation definition */ +export const DELETE_DRAFT_RECORD = gql` + mutation deleteDraftRecord($id: ID!) { + deleteDraftRecord(id: $id) { + id + } + } +`; diff --git a/libs/shared/src/lib/survey/components/utils.ts b/libs/shared/src/lib/survey/components/utils.ts index d789a0fab9..f35a03a9ec 100644 --- a/libs/shared/src/lib/survey/components/utils.ts +++ b/libs/shared/src/lib/survey/components/utils.ts @@ -3,7 +3,6 @@ import { UntypedFormControl } from '@angular/forms'; import { NgZone } from '@angular/core'; // todo: as it something to do with survey-angular import { SurveyModel, surveyLocalization } from 'survey-core'; -import localForage from 'localforage'; import { Question } from '../types'; /** @@ -118,6 +117,11 @@ export const buildAddButton = ( dialogRef.closed.subscribe((result: any) => { if (result) { const { data } = result; + question.template = result.template; + question.draftData = { + ...question.draftData, + [data.id]: data.data, + }; // TODO: call reload method // if (question.displayAsGrid && gridComponent) { // gridComponent.availableRecords.push({ @@ -185,50 +189,26 @@ export const processNewCreatedRecords = ( const temporaryRecords: any[] = []; if (multiselect) { question.newCreatedRecords?.forEach((recordId: string) => { - const promise = new Promise((resolve, reject) => { - localForage - .getItem(recordId) - .then((data: any) => { - if (data != null) { - // We ensure to make it only if such a record is found - const parsedData = JSON.parse(data); - temporaryRecords.push({ - id: recordId, - template: parsedData.template, - ...parsedData.data, - isTemporary: true, - }); - } - resolve(); - }) - .catch((error: any) => { - console.error(error); // Handle any errors that occur while getting the item - reject(error); - }); + const promise = new Promise((resolve) => { + temporaryRecords.push({ + id: recordId, + template: question.template, + ...question.draftData[recordId], + isTemporary: true, + }); + resolve(); }); promises.push(promise); }); } else { - new Promise((resolve, reject) => { - localForage - .getItem(question.newCreatedRecords) - .then((data: any) => { - if (data != null) { - // We ensure to make it only if such a record is found - const parsedData = JSON.parse(data); - temporaryRecords.push({ - id: question.newCreatedRecords, - template: parsedData.template, - ...parsedData.data, - isTemporary: true, - }); - } - resolve(); - }) - .catch((error: any) => { - console.error(error); // Handle any errors that occur while getting the item - reject(error); - }); + new Promise((resolve) => { + temporaryRecords.push({ + id: question.newCreatedRecords, + template: question.template, + ...question.draftData[question.newCreatedRecords], + isTemporary: true, + }); + resolve(); }); } @@ -246,8 +226,10 @@ export const processNewCreatedRecords = ( field: 'ids', operator: 'eq', value: + // Was used exclude the temporary records by excluding id in UUID format + // But we not longer use UUID or local storage for the temporary records question.value.filter((id: string) => !uuidRegExpr.test(id)) || - [], //We exclude the temporary records by excluding id in UUID format + [], }, ], }), diff --git a/libs/shared/src/lib/survey/types.ts b/libs/shared/src/lib/survey/types.ts index c2298bfd6f..d9bc43e3d6 100644 --- a/libs/shared/src/lib/survey/types.ts +++ b/libs/shared/src/lib/survey/types.ts @@ -73,4 +73,6 @@ export interface QuestionResource customFilter: string; displayAsGrid: boolean; remove?: boolean; + template?: string; + draftData?: any; }