Data loss warning on Angular editforms on window.beforeunload

pull/8766/head
Aleix Suau 4 years ago
parent 88e143099e
commit 4658cdd8a1
  1. 5
      frontend/src/app/globals/global-listeners.ts
  2. 6
      frontend/src/app/globals/openproject.ts
  3. 22
      frontend/src/app/modules/fields/edit/edit-form/edit-form.component.ts
  4. 54
      frontend/src/app/modules/fields/edit/field-types/formattable-edit-field.component.ts
  5. 50
      frontend/src/app/modules/fields/edit/services/global-edit-form-changes-tracker/global-edit-form-changes-tracker.service.spec.ts
  6. 26
      frontend/src/app/modules/fields/edit/services/global-edit-form-changes-tracker/global-edit-form-changes-tracker.service.ts

@ -92,11 +92,12 @@ import {detectOnboardingTour} from "core-app/globals/onboarding/onboarding_tour_
// Global beforeunload hook // Global beforeunload hook
$(window).on('beforeunload', (e:JQuery.TriggeredEvent) => { $(window).on('beforeunload', (e:JQuery.TriggeredEvent) => {
const event = e.originalEvent as BeforeUnloadEvent; const event = e.originalEvent as BeforeUnloadEvent;
if (window.OpenProject.pageWasEdited && !window.OpenProject.pageIsSubmitted) { if ((window.OpenProject.pageWasEdited && !window.OpenProject.pageIsSubmitted) ||
window.OpenProject.editFormsContainModelChanges) {
// Cancel the event // Cancel the event
event.preventDefault(); event.preventDefault();
// Chrome requires returnValue to be set // Chrome requires returnValue to be set
event.returnValue = ''; event.returnValue = window.I18n.t("js.work_packages.confirm_edit_cancel");
} }
}); });

@ -45,6 +45,12 @@ export class OpenProject {
/** Globally setable variable whether the page form is submitted. /** Globally setable variable whether the page form is submitted.
* Necessary to avoid a data loss warning on beforeunload */ * Necessary to avoid a data loss warning on beforeunload */
public pageIsSubmitted:boolean = false; public pageIsSubmitted:boolean = false;
/** Globally setable variable whether any of the EditFormComponent
* contain changes.
* Necessary to show a data loss warning on beforeunload when clicking
* on a link out of the Angular app (ie: main side menu)
* */
public editFormsContainModelChanges:boolean;
public getPluginContext():Promise<OpenProjectPluginContext> { public getPluginContext():Promise<OpenProjectPluginContext> {
return this.pluginContext return this.pluginContext

@ -44,6 +44,7 @@ import {EditFieldHandler} from "core-app/modules/fields/edit/editing-portal/edit
import {EditingPortalService} from "core-app/modules/fields/edit/editing-portal/editing-portal-service"; import {EditingPortalService} from "core-app/modules/fields/edit/editing-portal/editing-portal-service";
import {EditFormRoutingService} from "core-app/modules/fields/edit/edit-form/edit-form-routing.service"; import {EditFormRoutingService} from "core-app/modules/fields/edit/edit-form/edit-form-routing.service";
import {ResourceChangesetCommit} from "core-app/modules/fields/edit/services/hal-resource-editing.service"; import {ResourceChangesetCommit} from "core-app/modules/fields/edit/services/hal-resource-editing.service";
import {GlobalEditFormChangesTrackerService} from "core-app/modules/fields/edit/services/global-edit-form-changes-tracker/global-edit-form-changes-tracker.service";
@Component({ @Component({
selector: 'edit-form,[edit-form]', selector: 'edit-form,[edit-form]',
@ -57,6 +58,7 @@ export class EditFormComponent extends EditForm<HalResource> implements OnInit,
@Output('onSaved') onSavedEmitter = new EventEmitter<{ savedResource:HalResource, isInitial:boolean }>(); @Output('onSaved') onSavedEmitter = new EventEmitter<{ savedResource:HalResource, isInitial:boolean }>();
public fields:{ [attribute:string]:EditableAttributeFieldComponent } = {}; public fields:{ [attribute:string]:EditableAttributeFieldComponent } = {};
private fieldsWithModelChanges = new Map();
private registeredFields = input<string[]>(); private registeredFields = input<string[]>();
private unregisterListener:Function; private unregisterListener:Function;
@ -67,7 +69,8 @@ export class EditFormComponent extends EditForm<HalResource> implements OnInit,
protected readonly editingPortalService:EditingPortalService, protected readonly editingPortalService:EditingPortalService,
protected readonly $state:StateService, protected readonly $state:StateService,
protected readonly I18n:I18nService, protected readonly I18n:I18nService,
@Optional() protected readonly editFormRouting:EditFormRoutingService) { @Optional() protected readonly editFormRouting:EditFormRoutingService,
private globalEditFormChangesTrackerService:GlobalEditFormChangesTrackerService) {
super(injector); super(injector);
const confirmText = I18n.t('js.work_packages.confirm_edit_cancel'); const confirmText = I18n.t('js.work_packages.confirm_edit_cancel');
@ -207,4 +210,21 @@ export class EditFormComponent extends EditForm<HalResource> implements OnInit,
return hasDefault && !changed; return hasDefault && !changed;
} }
public get hasModelChanges() {
return this.fieldsWithModelChanges.size !== 0;
}
public addToFieldsWithModelChanges(field:any, value:any) {
this.fieldsWithModelChanges.set(field, value);
this.globalEditFormChangesTrackerService.addToFormsWithModelChanges(this);
}
public removeFromFieldsWithModelChanges(field:any) {
this.fieldsWithModelChanges.delete(field);
if (!this.hasModelChanges) {
this.globalEditFormChangesTrackerService.removeFromFormsWithModelChanges(this);
}
}
} }

@ -25,10 +25,28 @@
// See docs/COPYRIGHT.rdoc for more details. // See docs/COPYRIGHT.rdoc for more details.
// ++ // ++
import {Component, OnInit, ViewChild, ChangeDetectionStrategy} from "@angular/core"; import {
import {EditFieldComponent} from "core-app/modules/fields/edit/edit-field.component"; Component,
OnInit,
ViewChild,
ChangeDetectionStrategy,
ElementRef,
Inject,
ChangeDetectorRef, Injector, Optional, OnDestroy
} from "@angular/core";
import {
EditFieldComponent,
OpEditingPortalChangesetToken, OpEditingPortalHandlerToken,
OpEditingPortalSchemaToken
} from "core-app/modules/fields/edit/edit-field.component";
import {OpCkeditorComponent} from "core-app/modules/common/ckeditor/op-ckeditor.component"; import {OpCkeditorComponent} from "core-app/modules/common/ckeditor/op-ckeditor.component";
import {ICKEditorContext, ICKEditorInstance} from "core-app/modules/common/ckeditor/ckeditor-setup.service"; import {ICKEditorContext, ICKEditorInstance} from "core-app/modules/common/ckeditor/ckeditor-setup.service";
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import {ResourceChangeset} from "core-app/modules/fields/changeset/resource-changeset";
import {HalResource} from "core-app/modules/hal/resources/hal-resource";
import {IFieldSchema} from "core-app/modules/fields/field.base";
import {EditFieldHandler} from "core-app/modules/fields/edit/editing-portal/edit-field-handler";
import {EditFormComponent} from "core-app/modules/fields/edit/edit-form/edit-form.component";
export const formattableFieldTemplate = ` export const formattableFieldTemplate = `
<div class="textarea-wrapper"> <div class="textarea-wrapper">
@ -44,7 +62,7 @@ export const formattableFieldTemplate = `
<edit-field-controls *ngIf="!(handler.inEditMode || initializationError)" <edit-field-controls *ngIf="!(handler.inEditMode || initializationError)"
[fieldController]="field" [fieldController]="field"
(onSave)="handleUserSubmit()" (onSave)="handleUserSubmit()"
(onCancel)="handler.handleUserCancel()" (onCancel)="handleUserCancel()"
[saveTitle]="text.save" [saveTitle]="text.save"
[cancelTitle]="text.cancel"> [cancelTitle]="text.cancel">
</edit-field-controls> </edit-field-controls>
@ -55,7 +73,7 @@ export const formattableFieldTemplate = `
template: formattableFieldTemplate, template: formattableFieldTemplate,
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class FormattableEditFieldComponent extends EditFieldComponent implements OnInit { export class FormattableEditFieldComponent extends EditFieldComponent implements OnInit, OnDestroy {
public readonly field = this; public readonly field = this;
// Detect when inner component could not be initalized // Detect when inner component could not be initalized
@ -70,6 +88,18 @@ export class FormattableEditFieldComponent extends EditFieldComponent implements
public editorType = this.resource.getEditorTypeFor(this.field.name); public editorType = this.resource.getEditorTypeFor(this.field.name);
constructor(readonly I18n:I18nService,
readonly elementRef:ElementRef,
@Inject(OpEditingPortalChangesetToken) protected change:ResourceChangeset<HalResource>,
@Inject(OpEditingPortalSchemaToken) public schema:IFieldSchema,
@Inject(OpEditingPortalHandlerToken) readonly handler:EditFieldHandler,
readonly cdRef:ChangeDetectorRef,
readonly injector:Injector,
// Get parent field group from injector if we're in a form
@Optional() protected editForm:EditFormComponent) {
super(I18n, elementRef, change, schema, handler, cdRef, injector);
}
ngOnInit() { ngOnInit() {
super.ngOnInit(); super.ngOnInit();
@ -81,6 +111,12 @@ export class FormattableEditFieldComponent extends EditFieldComponent implements
}; };
} }
ngOnDestroy() {
super.ngOnDestroy();
this.editForm.removeFromFieldsWithModelChanges(this);
}
public onCkeditorSetup(editor:ICKEditorInstance) { public onCkeditorSetup(editor:ICKEditorInstance) {
if (!this.resource.isNew) { if (!this.resource.isNew) {
setTimeout(() => editor.editing.view.focus()); setTimeout(() => editor.editing.view.focus());
@ -100,6 +136,7 @@ export class FormattableEditFieldComponent extends EditFieldComponent implements
// in the changeset when no actual change has taken place. // in the changeset when no actual change has taken place.
if (this.rawValue !== value) { if (this.rawValue !== value) {
this.rawValue = value; this.rawValue = value;
this.editForm.addToFieldsWithModelChanges(this, this.rawValue);
} }
} }
@ -107,11 +144,18 @@ export class FormattableEditFieldComponent extends EditFieldComponent implements
this.getCurrentValue() this.getCurrentValue()
.then(() => { .then(() => {
this.handler.handleUserSubmit(); this.handler.handleUserSubmit();
this.editForm.removeFromFieldsWithModelChanges(this);
}); });
return false; return false;
} }
public handleUserCancel() {
this.editForm.removeFromFieldsWithModelChanges(this);
this.handler.handleUserCancel();
}
public get ckEditorContext():ICKEditorContext { public get ckEditorContext():ICKEditorContext {
return { return {
resource: this.change.pristineResource, resource: this.change.pristineResource,
@ -130,6 +174,8 @@ export class FormattableEditFieldComponent extends EditFieldComponent implements
this.editor.content = this.rawValue; this.editor.content = this.rawValue;
this.cdRef.markForCheck(); this.cdRef.markForCheck();
this.editForm.removeFromFieldsWithModelChanges(this);
} }
} }

@ -0,0 +1,50 @@
import { TestBed } from '@angular/core/testing';
import { GlobalEditFormChangesTrackerService } from './global-edit-form-changes-tracker.service';
describe('GlobalEditFormChangesTrackerService', () => {
let service:GlobalEditFormChangesTrackerService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(GlobalEditFormChangesTrackerService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
it('should not have model changes when created', () => {
expect(service.hasModelChanges).toBeFalsy();
});
it('should have model changes when one form is added', () => {
const editForm = 'editForm';
service.addToFormsWithModelChanges(editForm);
expect(service.hasModelChanges).toBeTruthy();
});
it('should have model changes while there are forms registered', () => {
const editForm = 'editForm';
const editForm2 = 'editForm2';
service.addToFormsWithModelChanges(editForm);
service.addToFormsWithModelChanges(editForm2);
service.removeFromFormsWithModelChanges(editForm);
expect(service.hasModelChanges).toBeTruthy();
});
it('should not have model changes when all the form have been removed', () => {
const editForm = 'editForm';
const editForm2 = 'editForm2';
service.addToFormsWithModelChanges(editForm);
service.addToFormsWithModelChanges(editForm2);
service.removeFromFormsWithModelChanges(editForm);
service.removeFromFormsWithModelChanges(editForm2);
expect(service.hasModelChanges).toBeFalsy();
});
});

@ -0,0 +1,26 @@
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class GlobalEditFormChangesTrackerService {
private formsWithModelChanges = new Map();
public get hasModelChanges() {
return this.formsWithModelChanges.size !== 0;
}
public addToFormsWithModelChanges(form:any) {
this.formsWithModelChanges.set(form, true);
window.OpenProject.editFormsContainModelChanges = true;
}
public removeFromFormsWithModelChanges(form:any) {
this.formsWithModelChanges.delete(form);
if (!this.hasModelChanges) {
window.OpenProject.editFormsContainModelChanges = false;
}
}
}
Loading…
Cancel
Save