diff --git a/src/Squidex/app/features/content/pages/content/content-page.component.ts b/src/Squidex/app/features/content/pages/content/content-page.component.ts index 879c5790a..f48290c9f 100644 --- a/src/Squidex/app/features/content/pages/content/content-page.component.ts +++ b/src/Squidex/app/features/content/pages/content/content-page.component.ts @@ -8,7 +8,7 @@ import { Component, OnInit, ViewChild } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { Observable, of } from 'rxjs'; -import { onErrorResumeNext, switchMap } from 'rxjs/operators'; +import { debounceTime, filter, onErrorResumeNext, switchMap, tap } from 'rxjs/operators'; import { ContentVersionSelected } from './../messages'; @@ -17,6 +17,8 @@ import { AppLanguageDto, AppsState, AuthService, + AutoSaveKey, + AutoSaveService, CanComponentDeactivate, ContentDto, ContentsState, @@ -45,6 +47,9 @@ import { DueTimeSelectorComponent } from './../../shared/due-time-selector.compo ] }) export class ContentPageComponent extends ResourceOwner implements CanComponentDeactivate, OnInit { + private isLoadingContent: boolean; + private autoSaveKey: AutoSaveKey; + public schema: SchemaDetailsDto; public formContext: any; @@ -65,6 +70,7 @@ export class ContentPageComponent extends ResourceOwner implements CanComponentD constructor(apiUrl: ApiUrlConfig, authService: AuthService, public readonly appsState: AppsState, public readonly contentsState: ContentsState, + private readonly autoSaveService: AutoSaveService, private readonly dialogs: DialogService, private readonly languagesState: LanguagesState, private readonly messageBus: MessageBus, @@ -100,11 +106,39 @@ export class ContentPageComponent extends ResourceOwner implements CanComponentD this.own( this.contentsState.selectedContent .subscribe(content => { + this.autoSaveKey = { + schemaId: this.schema.id, + schemaVersion: this.schema.version, + contentId: content ? content.id : undefined + }; + + const autosaved = this.autoSaveService.get(this.autoSaveKey); + if (content) { this.content = content; this.loadContent(this.content.dataDraft); } + + if (autosaved) { + this.dialogs.confirm('Unsaved changes', 'You have unsaved changes. Do you want to load them now?') + .subscribe(shouldLoad => { + if (shouldLoad) { + this.loadContent(autosaved); + } else { + this.autoSaveService.remove(this.autoSaveKey); + } + }); + } + })); + + this.own( + this.contentForm.form.valueChanges.pipe( + filter(_ => !this.isLoadingContent), + filter(_ => this.contentForm.form.enabled), + debounceTime(2000) + ).subscribe(value => { + this.autoSaveService.set(this.autoSaveKey, value); })); this.own( @@ -115,10 +149,16 @@ export class ContentPageComponent extends ResourceOwner implements CanComponentD } public canDeactivate(): Observable { - if (!this.contentForm.form.dirty) { + if (!this.contentForm.hasChanged(this.content)) { return of(true); } else { - return this.dialogs.confirm('Unsaved changes', 'You have unsaved changes, do you want to close the current content view and discard your changes?'); + return this.dialogs.confirm('Unsaved changes', 'You have unsaved changes, do you want to close the current content view and discard your changes?').pipe( + tap(confirmed => { + if (confirmed) { + this.autoSaveService.remove(this.autoSaveKey); + } + }) + ); } } @@ -138,6 +178,8 @@ export class ContentPageComponent extends ResourceOwner implements CanComponentD const value = this.contentForm.submit(); if (value) { + this.autoSaveService.remove(this.autoSaveKey); + if (this.content) { if (asDraft) { if (this.content && !this.content.canDraftPropose) { @@ -186,8 +228,16 @@ export class ContentPageComponent extends ResourceOwner implements CanComponentD } private loadContent(data: any) { - this.contentForm.loadContent(data); - this.contentForm.setEnabled(!this.content || this.content.canUpdateAny); + this.isLoadingContent = true; + + this.autoSaveService.remove(this.autoSaveKey); + + try { + this.contentForm.loadContent(data); + this.contentForm.setEnabled(!this.content || this.content.canUpdateAny); + } finally { + this.isLoadingContent = false; + } } public discardChanges() { @@ -250,4 +300,4 @@ export class ContentPageComponent extends ResourceOwner implements CanComponentD public trackByField(index: number, field: FieldDto) { return field.fieldId + this.schema.id; } -} \ No newline at end of file +} diff --git a/src/Squidex/app/framework/services/local-store.service.spec.ts b/src/Squidex/app/framework/services/local-store.service.spec.ts index 96e4dfcc0..6c2b517bd 100644 --- a/src/Squidex/app/framework/services/local-store.service.spec.ts +++ b/src/Squidex/app/framework/services/local-store.service.spec.ts @@ -97,4 +97,13 @@ describe('LocalStore', () => { expect(localStoreService.getInt('not_set', 13)).toBe(13); }); + + it('should remove item from local store', () => { + const localStoreService = new LocalStoreService(); + + localStoreService.set('key1', 'abc'); + localStoreService.remove('key1'); + + expect(localStoreService.get('key1')).toBeNull(); + }); }); diff --git a/src/Squidex/app/framework/services/local-store.service.ts b/src/Squidex/app/framework/services/local-store.service.ts index ad4d39ecb..9bd1376e3 100644 --- a/src/Squidex/app/framework/services/local-store.service.ts +++ b/src/Squidex/app/framework/services/local-store.service.ts @@ -14,7 +14,7 @@ export const LocalStoreServiceFactory = () => { @Injectable() export class LocalStoreService { private readonly fallback: { [key: string]: string } = {}; - private store: any = localStorage; + private store = localStorage; public configureStore(store: any) { this.store = store; @@ -59,4 +59,12 @@ export class LocalStoreService { this.store.setItem(key, converted); } + + public remove(key: string) { + try { + this.store.removeItem(key); + } catch (e) { + delete this.fallback[key]; + } + } } \ No newline at end of file diff --git a/src/Squidex/app/shared/internal.ts b/src/Squidex/app/shared/internal.ts index ededc4cfb..333ccb24c 100644 --- a/src/Squidex/app/shared/internal.ts +++ b/src/Squidex/app/shared/internal.ts @@ -7,6 +7,7 @@ export * from './interceptors/auth.interceptor'; +export * from './services/autosave.service'; export * from './services/app-languages.service'; export * from './services/apps.service'; export * from './services/assets.service'; diff --git a/src/Squidex/app/shared/module.ts b/src/Squidex/app/shared/module.ts index 7c2e93c19..4e23fc2ee 100644 --- a/src/Squidex/app/shared/module.ts +++ b/src/Squidex/app/shared/module.ts @@ -31,6 +31,7 @@ import { AssetUrlPipe, AuthInterceptor, AuthService, + AutoSaveService, BackupsService, BackupsState, ClientsService, @@ -189,25 +190,24 @@ export class SqxSharedModule { return { ngModule: SqxSharedModule, providers: [ - ClientsService, - ContributorsService, AppLanguagesService, AppMustExistGuard, - PatternsService, - RolesService, AppsService, AppsState, - AssetsState, AssetsService, + AssetsState, AssetUploaderState, AuthService, + AutoSaveService, BackupsService, BackupsState, + ClientsService, ClientsState, CommentsService, ContentMustExistGuard, ContentsService, ContentsState, + ContributorsService, ContributorsState, GraphQlService, HelpService, @@ -219,9 +219,11 @@ export class SqxSharedModule { MustBeAuthenticatedGuard, MustBeNotAuthenticatedGuard, NewsService, + PatternsService, PatternsState, PlansService, PlansState, + RolesService, RolesState, RuleEventsState, RulesService, diff --git a/src/Squidex/app/shared/services/autosave.service.spec.ts b/src/Squidex/app/shared/services/autosave.service.spec.ts new file mode 100644 index 000000000..cf758968d --- /dev/null +++ b/src/Squidex/app/shared/services/autosave.service.spec.ts @@ -0,0 +1,110 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { IMock, It, Mock, Times } from 'typemoq'; + +import { + AutoSaveService, + LocalStoreService, + Version +} from '@app/shared/internal'; + +describe('AutoSaveService', () => { + let localStore: IMock; + + let autoSaveService: AutoSaveService; + + beforeEach(() => { + localStore = Mock.ofType(LocalStoreService); + + autoSaveService = new AutoSaveService(localStore.object); + }); + + it('should remove unsaved created content', () => { + autoSaveService.remove({ schemaId: '1', schemaVersion: new Version('2') }); + + expect().nothing(); + + localStore.verify(x => x.remove('autosave.1-2'), Times.once()); + }); + + it('should remove unsaved edited content', () => { + autoSaveService.remove({ schemaId: '1', schemaVersion: new Version('2'), contentId: '3' }); + + expect().nothing(); + + localStore.verify(x => x.remove('autosave.1-2.3'), Times.once()); + }); + + it('should not remove content if key is not defined', () => { + autoSaveService.remove(null!); + + expect().nothing(); + + localStore.verify(x => x.remove(It.isAnyString()), Times.never()); + }); + + it('should save unsaved created content', () => { + autoSaveService.set({ schemaId: '1', schemaVersion: new Version('2') }, { text: 'Hello' }); + + expect().nothing(); + + localStore.verify(x => x.set('autosave.1-2', '{"text":"Hello"}'), Times.once()); + }); + + it('should save unsaved edited content', () => { + autoSaveService.set({ schemaId: '1', schemaVersion: new Version('2'), contentId: '3' }, { text: 'Hello' }); + + expect().nothing(); + + localStore.verify(x => x.set('autosave.1-2.3', '{"text":"Hello"}'), Times.once()); + }); + + it('should not save content if key is not defined', () => { + autoSaveService.set(null!, { text: 'Hello' }); + + expect().nothing(); + + localStore.verify(x => x.set(It.isAnyString(), It.isAnyString()), Times.never()); + }); + + it('should not save content if content is not defined', () => { + autoSaveService.set({ schemaId: '1', schemaVersion: new Version('2') }, null!); + + expect().nothing(); + + localStore.verify(x => x.set(It.isAnyString(), It.isAnyString()), Times.never()); + }); + + it('should get unsaved created content', () => { + localStore.setup(x => x.get('autosave.1-2')) + .returns(() => '{"text":"Hello"}'); + + const content = autoSaveService.get({ schemaId: '1', schemaVersion: new Version('2') }); + + expect(content).toEqual({ text: 'Hello' }); + }); + + it('should get unsaved edited content', () => { + localStore.setup(x => x.get('autosave.1-2.3')) + .returns(() => '{"text":"Hello"}'); + + const content = autoSaveService.get({ schemaId: '1', schemaVersion: new Version('2'), contentId: '3' }); + + expect(content).toEqual({ text: 'Hello' }); + }); + + it('should not get content if key is not defined', () => { + autoSaveService.remove(null!); + + const content = autoSaveService.get(null!); + + expect(content).toBeNull(); + + localStore.verify(x => x.get(It.isAnyString()), Times.never()); + }); +}); \ No newline at end of file diff --git a/src/Squidex/app/shared/services/autosave.service.ts b/src/Squidex/app/shared/services/autosave.service.ts new file mode 100644 index 000000000..98d745e57 --- /dev/null +++ b/src/Squidex/app/shared/services/autosave.service.ts @@ -0,0 +1,64 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { Injectable } from '@angular/core'; + +import { LocalStoreService, Version } from '@app/framework'; + +export declare type AutoSaveKey = { schemaId: string, schemaVersion: Version, contentId?: string }; + +@Injectable() +export class AutoSaveService { + constructor( + private readonly localStore: LocalStoreService + ) { + } + + public get(key: AutoSaveKey): object | null { + if (!key) { + return null; + } + + const value = this.localStore.get(getKey(key)); + + if (value) { + return JSON.parse(value); + } + + return null; + } + + public set(key: AutoSaveKey, content: object) { + if (!key || !content) { + return; + } + + const json = JSON.stringify(content); + + this.localStore.set(getKey(key), json); + } + + public remove(key: AutoSaveKey) { + if (!key) { + return; + } + + this.localStore.remove(getKey(key)); + } +} + +function getKey(key: AutoSaveKey) { + let { contentId, schemaId, schemaVersion } = key; + + if (!contentId) { + contentId = ''; + } else { + contentId = `.${contentId}`; + } + + return `autosave.${schemaId}-${schemaVersion.value}${contentId}`; +} \ No newline at end of file diff --git a/src/Squidex/app/shared/state/contents.forms.ts b/src/Squidex/app/shared/state/contents.forms.ts index 306699712..608c08e5d 100644 --- a/src/Squidex/app/shared/state/contents.forms.ts +++ b/src/Squidex/app/shared/state/contents.forms.ts @@ -434,6 +434,12 @@ export class EditContentForm extends Form { this.enable(); } + public hasChanged(content?: ContentDto) { + const data = content ? content.dataDraft : {}; + + return !Types.jsJsonEquals(this.form.value, data); + } + public removeArrayItem(field: RootFieldDto, language: AppLanguageDto, index: number) { this.findArrayItemForm(field, language).removeAt(index); } @@ -521,9 +527,13 @@ export class EditContentForm extends Form { super.load(value); } + public disable() { + this.form.disable({ emitEvent: false }); + } + protected enable() { if (this.schema.fields.length === 0) { - this.form.enable(); + this.form.enable({ emitEvent: false }); return; } @@ -535,7 +545,7 @@ export class EditContentForm extends Form { } if (field.isArray) { - fieldForm.enable(); + fieldForm.enable({ emitEvent: false }); for (let partitionForm of formControls(fieldForm)) { for (let itemForm of formControls(partitionForm)) { @@ -547,17 +557,17 @@ export class EditContentForm extends Form { } if (nested.isDisabled) { - nestedForm.disable(); + nestedForm.disable({ emitEvent: false }); } else { - nestedForm.enable(); + nestedForm.enable({ emitEvent: false }); } } } } } else if (field.isDisabled) { - fieldForm.disable(); + fieldForm.disable({ emitEvent: false }); } else { - fieldForm.enable(); + fieldForm.enable({ emitEvent: false }); } } }