Browse Source

Autosave function. (#397)

* Autosave feature
pull/409/head
Sebastian Stehle 7 years ago
committed by GitHub
parent
commit
79fdfbb031
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 62
      src/Squidex/app/features/content/pages/content/content-page.component.ts
  2. 9
      src/Squidex/app/framework/services/local-store.service.spec.ts
  3. 10
      src/Squidex/app/framework/services/local-store.service.ts
  4. 1
      src/Squidex/app/shared/internal.ts
  5. 12
      src/Squidex/app/shared/module.ts
  6. 110
      src/Squidex/app/shared/services/autosave.service.spec.ts
  7. 64
      src/Squidex/app/shared/services/autosave.service.ts
  8. 22
      src/Squidex/app/shared/state/contents.forms.ts

62
src/Squidex/app/features/content/pages/content/content-page.component.ts

@ -8,7 +8,7 @@
import { Component, OnInit, ViewChild } from '@angular/core'; import { Component, OnInit, ViewChild } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { Observable, of } from 'rxjs'; import { Observable, of } from 'rxjs';
import { onErrorResumeNext, switchMap } from 'rxjs/operators'; import { debounceTime, filter, onErrorResumeNext, switchMap, tap } from 'rxjs/operators';
import { ContentVersionSelected } from './../messages'; import { ContentVersionSelected } from './../messages';
@ -17,6 +17,8 @@ import {
AppLanguageDto, AppLanguageDto,
AppsState, AppsState,
AuthService, AuthService,
AutoSaveKey,
AutoSaveService,
CanComponentDeactivate, CanComponentDeactivate,
ContentDto, ContentDto,
ContentsState, ContentsState,
@ -45,6 +47,9 @@ import { DueTimeSelectorComponent } from './../../shared/due-time-selector.compo
] ]
}) })
export class ContentPageComponent extends ResourceOwner implements CanComponentDeactivate, OnInit { export class ContentPageComponent extends ResourceOwner implements CanComponentDeactivate, OnInit {
private isLoadingContent: boolean;
private autoSaveKey: AutoSaveKey;
public schema: SchemaDetailsDto; public schema: SchemaDetailsDto;
public formContext: any; public formContext: any;
@ -65,6 +70,7 @@ export class ContentPageComponent extends ResourceOwner implements CanComponentD
constructor(apiUrl: ApiUrlConfig, authService: AuthService, constructor(apiUrl: ApiUrlConfig, authService: AuthService,
public readonly appsState: AppsState, public readonly appsState: AppsState,
public readonly contentsState: ContentsState, public readonly contentsState: ContentsState,
private readonly autoSaveService: AutoSaveService,
private readonly dialogs: DialogService, private readonly dialogs: DialogService,
private readonly languagesState: LanguagesState, private readonly languagesState: LanguagesState,
private readonly messageBus: MessageBus, private readonly messageBus: MessageBus,
@ -100,11 +106,39 @@ export class ContentPageComponent extends ResourceOwner implements CanComponentD
this.own( this.own(
this.contentsState.selectedContent this.contentsState.selectedContent
.subscribe(content => { .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) { if (content) {
this.content = content; this.content = content;
this.loadContent(this.content.dataDraft); 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( this.own(
@ -115,10 +149,16 @@ export class ContentPageComponent extends ResourceOwner implements CanComponentD
} }
public canDeactivate(): Observable<boolean> { public canDeactivate(): Observable<boolean> {
if (!this.contentForm.form.dirty) { if (!this.contentForm.hasChanged(this.content)) {
return of(true); return of(true);
} else { } 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(); const value = this.contentForm.submit();
if (value) { if (value) {
this.autoSaveService.remove(this.autoSaveKey);
if (this.content) { if (this.content) {
if (asDraft) { if (asDraft) {
if (this.content && !this.content.canDraftPropose) { if (this.content && !this.content.canDraftPropose) {
@ -186,8 +228,16 @@ export class ContentPageComponent extends ResourceOwner implements CanComponentD
} }
private loadContent(data: any) { private loadContent(data: any) {
this.contentForm.loadContent(data); this.isLoadingContent = true;
this.contentForm.setEnabled(!this.content || this.content.canUpdateAny);
this.autoSaveService.remove(this.autoSaveKey);
try {
this.contentForm.loadContent(data);
this.contentForm.setEnabled(!this.content || this.content.canUpdateAny);
} finally {
this.isLoadingContent = false;
}
} }
public discardChanges() { public discardChanges() {
@ -250,4 +300,4 @@ export class ContentPageComponent extends ResourceOwner implements CanComponentD
public trackByField(index: number, field: FieldDto) { public trackByField(index: number, field: FieldDto) {
return field.fieldId + this.schema.id; return field.fieldId + this.schema.id;
} }
} }

9
src/Squidex/app/framework/services/local-store.service.spec.ts

@ -97,4 +97,13 @@ describe('LocalStore', () => {
expect(localStoreService.getInt('not_set', 13)).toBe(13); 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();
});
}); });

10
src/Squidex/app/framework/services/local-store.service.ts

@ -14,7 +14,7 @@ export const LocalStoreServiceFactory = () => {
@Injectable() @Injectable()
export class LocalStoreService { export class LocalStoreService {
private readonly fallback: { [key: string]: string } = {}; private readonly fallback: { [key: string]: string } = {};
private store: any = localStorage; private store = localStorage;
public configureStore(store: any) { public configureStore(store: any) {
this.store = store; this.store = store;
@ -59,4 +59,12 @@ export class LocalStoreService {
this.store.setItem(key, converted); this.store.setItem(key, converted);
} }
public remove(key: string) {
try {
this.store.removeItem(key);
} catch (e) {
delete this.fallback[key];
}
}
} }

1
src/Squidex/app/shared/internal.ts

@ -7,6 +7,7 @@
export * from './interceptors/auth.interceptor'; export * from './interceptors/auth.interceptor';
export * from './services/autosave.service';
export * from './services/app-languages.service'; export * from './services/app-languages.service';
export * from './services/apps.service'; export * from './services/apps.service';
export * from './services/assets.service'; export * from './services/assets.service';

12
src/Squidex/app/shared/module.ts

@ -31,6 +31,7 @@ import {
AssetUrlPipe, AssetUrlPipe,
AuthInterceptor, AuthInterceptor,
AuthService, AuthService,
AutoSaveService,
BackupsService, BackupsService,
BackupsState, BackupsState,
ClientsService, ClientsService,
@ -189,25 +190,24 @@ export class SqxSharedModule {
return { return {
ngModule: SqxSharedModule, ngModule: SqxSharedModule,
providers: [ providers: [
ClientsService,
ContributorsService,
AppLanguagesService, AppLanguagesService,
AppMustExistGuard, AppMustExistGuard,
PatternsService,
RolesService,
AppsService, AppsService,
AppsState, AppsState,
AssetsState,
AssetsService, AssetsService,
AssetsState,
AssetUploaderState, AssetUploaderState,
AuthService, AuthService,
AutoSaveService,
BackupsService, BackupsService,
BackupsState, BackupsState,
ClientsService,
ClientsState, ClientsState,
CommentsService, CommentsService,
ContentMustExistGuard, ContentMustExistGuard,
ContentsService, ContentsService,
ContentsState, ContentsState,
ContributorsService,
ContributorsState, ContributorsState,
GraphQlService, GraphQlService,
HelpService, HelpService,
@ -219,9 +219,11 @@ export class SqxSharedModule {
MustBeAuthenticatedGuard, MustBeAuthenticatedGuard,
MustBeNotAuthenticatedGuard, MustBeNotAuthenticatedGuard,
NewsService, NewsService,
PatternsService,
PatternsState, PatternsState,
PlansService, PlansService,
PlansState, PlansState,
RolesService,
RolesState, RolesState,
RuleEventsState, RuleEventsState,
RulesService, RulesService,

110
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<LocalStoreService>;
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());
});
});

64
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}`;
}

22
src/Squidex/app/shared/state/contents.forms.ts

@ -434,6 +434,12 @@ export class EditContentForm extends Form<FormGroup, any> {
this.enable(); 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) { public removeArrayItem(field: RootFieldDto, language: AppLanguageDto, index: number) {
this.findArrayItemForm(field, language).removeAt(index); this.findArrayItemForm(field, language).removeAt(index);
} }
@ -521,9 +527,13 @@ export class EditContentForm extends Form<FormGroup, any> {
super.load(value); super.load(value);
} }
public disable() {
this.form.disable({ emitEvent: false });
}
protected enable() { protected enable() {
if (this.schema.fields.length === 0) { if (this.schema.fields.length === 0) {
this.form.enable(); this.form.enable({ emitEvent: false });
return; return;
} }
@ -535,7 +545,7 @@ export class EditContentForm extends Form<FormGroup, any> {
} }
if (field.isArray) { if (field.isArray) {
fieldForm.enable(); fieldForm.enable({ emitEvent: false });
for (let partitionForm of formControls(fieldForm)) { for (let partitionForm of formControls(fieldForm)) {
for (let itemForm of formControls(partitionForm)) { for (let itemForm of formControls(partitionForm)) {
@ -547,17 +557,17 @@ export class EditContentForm extends Form<FormGroup, any> {
} }
if (nested.isDisabled) { if (nested.isDisabled) {
nestedForm.disable(); nestedForm.disable({ emitEvent: false });
} else { } else {
nestedForm.enable(); nestedForm.enable({ emitEvent: false });
} }
} }
} }
} }
} else if (field.isDisabled) { } else if (field.isDisabled) {
fieldForm.disable(); fieldForm.disable({ emitEvent: false });
} else { } else {
fieldForm.enable(); fieldForm.enable({ emitEvent: false });
} }
} }
} }

Loading…
Cancel
Save