From 572ac6d19110191c1f2ed7fbf14474276c907b70 Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Mon, 29 Apr 2019 17:27:05 +0200 Subject: [PATCH] Improved cancellation. --- .../assets/pages/assets-page.component.html | 2 +- .../pages/content/content-page.component.html | 1 - .../shared/content-status.component.html | 4 +- .../shared/content-status.component.scss | 8 +- .../shared/content-status.component.ts | 3 - .../app/framework/utils/rxjs-extensions.ts | 20 ++-- .../components/asset-uploader.component.html | 70 ++++++------ .../components/asset-uploader.component.ts | 4 +- .../app/shared/components/asset.component.ts | 6 +- .../components/markdown-editor.component.ts | 11 +- .../components/rich-editor.component.ts | 17 ++- src/Squidex/app/shared/state/apps.state.ts | 8 +- .../app/shared/state/asset-uploader.state.ts | 58 ++++------ src/Squidex/app/shared/state/assets.state.ts | 8 +- .../app/shared/state/contents.state.ts | 106 ++++++++++-------- src/Squidex/app/shared/state/plans.state.ts | 8 +- .../app/shared/state/rule-events.state.ts | 6 +- src/Squidex/app/shared/state/schemas.state.ts | 17 ++- src/Squidex/app/shared/state/ui.state.ts | 8 +- 19 files changed, 192 insertions(+), 173 deletions(-) diff --git a/src/Squidex/app/features/assets/pages/assets-page.component.html b/src/Squidex/app/features/assets/pages/assets-page.component.html index 0a3f75e50..d169e27fe 100644 --- a/src/Squidex/app/features/assets/pages/assets-page.component.html +++ b/src/Squidex/app/features/assets/pages/assets-page.component.html @@ -7,7 +7,7 @@
-
+
-
-
-
{{upload.name}}
-
-
- -
-
-
- - - \ No newline at end of file + + +
\ No newline at end of file diff --git a/src/Squidex/app/shared/components/asset-uploader.component.ts b/src/Squidex/app/shared/components/asset-uploader.component.ts index df6b81565..7160ba9e0 100644 --- a/src/Squidex/app/shared/components/asset-uploader.component.ts +++ b/src/Squidex/app/shared/components/asset-uploader.component.ts @@ -8,6 +8,7 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; import { + AppsState, AssetsState, AssetUploaderState, DialogModel, @@ -25,9 +26,10 @@ import { changeDetection: ChangeDetectionStrategy.OnPush }) export class AssetUploaderComponent { - public modalMenu = new DialogModel(true); + public modalMenu = new DialogModel(); constructor( + public readonly appsState: AppsState, public readonly assetUploader: AssetUploaderState, public readonly assetsState: AssetsState ) { diff --git a/src/Squidex/app/shared/components/asset.component.ts b/src/Squidex/app/shared/components/asset.component.ts index 2f6d989ce..253d111a8 100644 --- a/src/Squidex/app/shared/components/asset.component.ts +++ b/src/Squidex/app/shared/components/asset.component.ts @@ -15,7 +15,7 @@ import { StatefulComponent, Types } from '@app/shared/internal'; -import { AssetUploaderState } from '../state/asset-uploader.state'; +import { AssetUploaderState, UploadCanceled } from '../state/asset-uploader.state'; interface State { progress: number; @@ -101,7 +101,9 @@ export class AssetComponent extends StatefulComponent implements OnInit { this.emitLoad(dto); } }, error => { - this.dialogs.notifyError(error); + if (!Types.is(error, UploadCanceled)) { + this.dialogs.notifyError(error); + } this.emitLoadError(error); }); diff --git a/src/Squidex/app/shared/components/markdown-editor.component.ts b/src/Squidex/app/shared/components/markdown-editor.component.ts index 86400b285..237f33a6c 100644 --- a/src/Squidex/app/shared/components/markdown-editor.component.ts +++ b/src/Squidex/app/shared/components/markdown-editor.component.ts @@ -10,12 +10,13 @@ import { NG_VALUE_ACCESSOR } from '@angular/forms'; import { AssetDto, + AssetUploaderState, DialogModel, ResourceLoaderService, StatefulControlComponent, - Types + Types, + UploadCanceled } from '@app/shared/internal'; -import { AssetUploaderState } from '../state/asset-uploader.state'; declare var SimpleMDE: any; @@ -245,8 +246,10 @@ export class MarkdownEditorComponent extends StatefulControlComponent { - replaceText('FAILED'); + }, error => { + if (!Types.is(error, UploadCanceled)) { + replaceText('FAILED'); + } }); } } \ No newline at end of file diff --git a/src/Squidex/app/shared/components/rich-editor.component.ts b/src/Squidex/app/shared/components/rich-editor.component.ts index 8612dd002..1f9ea859d 100644 --- a/src/Squidex/app/shared/components/rich-editor.component.ts +++ b/src/Squidex/app/shared/components/rich-editor.component.ts @@ -12,12 +12,13 @@ import { NG_VALUE_ACCESSOR } from '@angular/forms'; import { AssetDto, + AssetUploaderState, DialogModel, ResourceLoaderService, StatefulControlComponent, - Types + Types, + UploadCanceled } from '@app/shared/internal'; -import { AssetUploaderState } from '../state/asset-uploader.state'; declare var tinymce: any; @@ -99,8 +100,10 @@ export class RichEditorComponent extends StatefulControlComponent i if (Types.is(asset, AssetDto)) { success(asset.url); } - }, () => { - failed(); + }, error => { + if (!Types.is(error, UploadCanceled)) { + failed(); + } }); }, @@ -215,8 +218,10 @@ export class RichEditorComponent extends StatefulControlComponent i if (Types.is(asset, AssetDto)) { replaceText(`${asset.fileName}`); } - }, () => { - replaceText('FAILED'); + }, error => { + if (!Types.is(error, UploadCanceled)) { + replaceText('FAILED'); + } }); } } \ No newline at end of file diff --git a/src/Squidex/app/shared/state/apps.state.ts b/src/Squidex/app/shared/state/apps.state.ts index 451ded8b3..2a97377cd 100644 --- a/src/Squidex/app/shared/state/apps.state.ts +++ b/src/Squidex/app/shared/state/apps.state.ts @@ -69,9 +69,9 @@ export class AppsState extends State { public load(): Observable { return this.appsService.getApps().pipe( - tap((dto: AppDto[]) => { + tap(payload => { this.next(s => { - const apps = ImmutableArray.of(dto); + const apps = ImmutableArray.of(payload); return { ...s, apps }; }); @@ -81,9 +81,9 @@ export class AppsState extends State { public create(request: CreateAppDto): Observable { return this.appsService.postApp(request).pipe( - tap(dto => { + tap(payload => { this.next(s => { - const apps = s.apps.push(dto).sortByStringAsc(x => x.name); + const apps = s.apps.push(payload).sortByStringAsc(x => x.name); return { ...s, apps }; }); diff --git a/src/Squidex/app/shared/state/asset-uploader.state.ts b/src/Squidex/app/shared/state/asset-uploader.state.ts index 4d2dece60..58c8ac741 100644 --- a/src/Squidex/app/shared/state/asset-uploader.state.ts +++ b/src/Squidex/app/shared/state/asset-uploader.state.ts @@ -6,8 +6,8 @@ */ import { Injectable } from '@angular/core'; -import { Observable, ReplaySubject, Subject, Subscription } from 'rxjs'; -import { distinctUntilChanged, map, shareReplay } from 'rxjs/operators'; +import { Observable, Subject } from 'rxjs'; +import { distinctUntilChanged, map, publishReplay, refCount, takeUntil } from 'rxjs/operators'; import { DateTime, @@ -31,10 +31,7 @@ export interface Upload { name: string; // The upload subscription. - subscription?: Subscription; - - // The subject to notify subscribers. - subject: ReplaySubject; + cancel: Subject; // The progress. progress: number; @@ -48,6 +45,8 @@ interface Snapshot { uploads: UploadList; } +export class UploadCanceled { } + type UploadList = ImmutableArray; type UploadResult = AssetDto | number; @@ -67,10 +66,7 @@ export class AssetUploaderState extends State { } public stopUpload(upload: Upload) { - if (upload.subscription) { - upload.subscription.unsubscribe(); - upload.subject.complete(); - } + upload.cancel.error(new UploadCanceled()); this.next(s => { const uploads = s.uploads.removeBy('id', upload); @@ -82,55 +78,49 @@ export class AssetUploaderState extends State { public uploadFile(file: File, target?: AssetsState, now?: DateTime): Observable { const stream = this.assetsService.uploadFile(this.appName, file, this.user, now || DateTime.now()); - return this.upload(stream, MathHelper.guid(), file, (asset, subject) => { + return this.upload(stream, MathHelper.guid(), file, asset => { if (asset.isDuplicate) { this.dialogs.notifyError('Asset has already been uploaded.'); } else if (target) { target.add(asset); } - subject.next(asset); + return asset; }); } public uploadAsset(asset: AssetDto, file: File, now?: DateTime): Observable { const stream = this.assetsService.replaceFile(this.appName, asset.id, file, asset.version); - return this.upload(stream, asset.id, file, ({ version, payload }, subject) => { - subject.next(asset.update(payload, this.user, version, now)); - }); + return this.upload(stream, asset.id, file, ({ version, payload }) => asset.update(payload, this.user, version, now)); } - private upload(stream: Observable, id: string, file: File, complete: (completion: R, subject: Subject) => void) { - const subject = new ReplaySubject(); - - let upload = { id, name: file.name, progress: 1, status: 'Running', subject }; + private upload(source: Observable, id: string, file: File, complete: ((completion: T) => AssetDto)) { + let upload = { id, name: file.name, progress: 1, status: 'Running', cancel: new Subject() }; this.addUpload(upload); - const subscription = stream.subscribe(event => { + const stream = source.pipe(takeUntil(upload.cancel), + map(event => { + if (Types.isNumber(event)) { + return event; + } else { + return complete(event); + } + }), + publishReplay(), refCount()); + + stream.subscribe(event => { if (Types.isNumber(event)) { upload = this.update(upload, { progress: event }); - - subject.next(event); - } else { - complete(event, subject); } - }, error => { - subject.error(error); - + }, () => { upload = this.remove(upload, { status: 'Failed' }); }, () => { - if (!subject.isStopped) { - subject.complete(); - } - upload = this.remove(upload, { status: 'Completed', progress: 100 }); }); - upload = this.update(upload, { subscription }); - - return subject.pipe(shareReplay()); + return stream; } private remove(upload: Upload, update: Partial) { diff --git a/src/Squidex/app/shared/state/assets.state.ts b/src/Squidex/app/shared/state/assets.state.ts index 3beab3093..66d726c6b 100644 --- a/src/Squidex/app/shared/state/assets.state.ts +++ b/src/Squidex/app/shared/state/assets.state.ts @@ -95,16 +95,16 @@ export class AssetsState extends State { Object.keys(this.snapshot.tagsSelected)), this.assetsService.getTags(this.appName) ).pipe( - tap(dtos => { + tap(([ { items, total }, tags ]) => { if (isReload) { this.dialogs.notifyInfo('Assets reloaded.'); } this.next(s => { - const assets = ImmutableArray.of(dtos[0].items); - const assetsPager = s.assetsPager.setCount(dtos[0].total); + const assets = ImmutableArray.of(items); + const assetsPager = s.assetsPager.setCount(total); - return { ...s, assets, assetsPager, isLoaded: true, tags: dtos[1] }; + return { ...s, assets, assetsPager, isLoaded: true, tags }; }); }), shareSubscribed(this.dialogs)); diff --git a/src/Squidex/app/shared/state/contents.state.ts b/src/Squidex/app/shared/state/contents.state.ts index 8f0461f10..787c27962 100644 --- a/src/Squidex/app/shared/state/contents.state.ts +++ b/src/Squidex/app/shared/state/contents.state.ts @@ -14,6 +14,7 @@ import { DialogService, ErrorDto, ImmutableArray, + mapVersioned, Pager, shareSubscribed, State, @@ -118,20 +119,24 @@ export abstract class ContentsStateBase extends State { return this.loadInternal(isReload); } - private loadInternal(isReload = false): Observable { + private loadInternal(isReload = false) { + return this.loadInternalCore(isReload).pipe(shareSubscribed(this.dialogs)); + } + + private loadInternalCore(isReload = false) { return this.contentsService.getContents(this.appName, this.schemaName, this.snapshot.contentsPager.pageSize, this.snapshot.contentsPager.skip, this.snapshot.contentsQuery, undefined, this.snapshot.isArchive).pipe( - tap(dtos => { + tap(({ total, items }) => { if (isReload) { this.dialogs.notifyInfo('Contents reloaded.'); } return this.next(s => { - const contents = ImmutableArray.of(dtos.items); - const contentsPager = s.contentsPager.setCount(dtos.total); + const contents = ImmutableArray.of(items); + const contentsPager = s.contentsPager.setCount(total); let selectedContent = s.selectedContent; @@ -141,17 +146,16 @@ export abstract class ContentsStateBase extends State { return { ...s, contents, contentsPager, selectedContent, isLoaded: true }; }); - }), - shareSubscribed(this.dialogs)); + })); } public create(request: any, publish: boolean): Observable { return this.contentsService.postContent(this.appName, this.schemaName, request, publish).pipe( - tap(dto => { + tap(payload => { this.dialogs.notifyInfo('Contents created successfully.'); return this.next(s => { - const contents = s.contents.pushFront(dto); + const contents = s.contents.pushFront(payload); const contentsPager = s.contentsPager.incrementCount(); return { ...s, contents, contentsPager }; @@ -174,7 +178,8 @@ export abstract class ContentsStateBase extends State { return of(error); }), - switchMap(() => this.loadInternal())); + switchMap(() => this.loadInternalCore()), + shareSubscribed(this.dialogs, { silent: true })); } public deleteMany(contents: ContentDto[]): Observable { @@ -191,92 +196,97 @@ export abstract class ContentsStateBase extends State { return of(error); }), - switchMap(() => this.loadInternal())); + switchMap(() => this.loadInternal()), + shareSubscribed(this.dialogs, { silent: true })); } - public publishChanges(content: ContentDto, dueTime: string | null, now?: DateTime): Observable { + public publishChanges(content: ContentDto, dueTime: string | null, now?: DateTime): Observable { return this.contentsService.changeContentStatus(this.appName, this.schemaName, content.id, 'Publish', dueTime, content.version).pipe( - tap(dto => { - this.dialogs.notifyInfo('Content updated successfully.'); - + mapVersioned(({ version }) => { if (dueTime) { - this.replaceContent(changeScheduleStatus(content, 'Published', dueTime, this.user, dto.version, now)); + return changeScheduleStatus(content, 'Published', dueTime, this.user, version, now); } else { - this.replaceContent(confirmChanges(content, this.user, dto.version, now)); + return confirmChanges(content, this.user, version, now); } }), + tap(({ payload }) => { + this.dialogs.notifyInfo('Content updated successfully.'); + + this.replaceContent(payload); + }), shareSubscribed(this.dialogs)); } - public changeStatus(content: ContentDto, action: string, status: string, dueTime: string | null, now?: DateTime): Observable { + public changeStatus(content: ContentDto, action: string, status: string, dueTime: string | null, now?: DateTime): Observable { return this.contentsService.changeContentStatus(this.appName, this.schemaName, content.id, action, dueTime, content.version).pipe( - tap(dto => { - this.dialogs.notifyInfo('Content updated successfully.'); - + mapVersioned(({ version }) => { if (dueTime) { - this.replaceContent(changeScheduleStatus(content, status, dueTime, this.user, dto.version, now)); + return changeScheduleStatus(content, status, dueTime, this.user, version, now); } else { - this.replaceContent(changeStatus(content, status, this.user, dto.version, now)); + return changeStatus(content, status, this.user, version, now); } }), + tap(({ payload }) => { + this.dialogs.notifyInfo('Content updated successfully.'); + + this.replaceContent(payload); + }), shareSubscribed(this.dialogs)); } - public update(content: ContentDto, request: any, now?: DateTime): Observable { + public update(content: ContentDto, request: any, now?: DateTime): Observable { return this.contentsService.putContent(this.appName, this.schemaName, content.id, request, false, content.version).pipe( - tap(dto => { + mapVersioned((payload, version) => updateData(content, payload, this.user, version, now)), + tap(({ payload }) => { this.dialogs.notifyInfo('Content updated successfully.'); - if (dto.version.value !== content.version.value) { - this.replaceContent(updateData(content, dto.payload, this.user, dto.version, now)); - } + this.replaceContent(payload, content.version); }), shareSubscribed(this.dialogs)); } - public proposeUpdate(content: ContentDto, request: any, now?: DateTime): Observable { + public proposeUpdate(content: ContentDto, request: any, now?: DateTime): Observable { return this.contentsService.putContent(this.appName, this.schemaName, content.id, request, true, content.version).pipe( - tap(dto => { + mapVersioned((payload, version) => updateDataDraft(content, payload, this.user, version, now)), + tap(({ payload }) => { this.dialogs.notifyInfo('Content updated successfully.'); - if (dto.version.value !== content.version.value) { - this.replaceContent(updateDataDraft(content, dto.payload, this.user, dto.version, now)); - } + this.replaceContent(payload, content.version); }), shareSubscribed(this.dialogs)); } - public discardChanges(content: ContentDto, now?: DateTime): Observable { + public discardChanges(content: ContentDto, now?: DateTime): Observable { return this.contentsService.discardChanges(this.appName, this.schemaName, content.id, content.version).pipe( - tap(dto => { + mapVersioned((_, version) => discardChanges(content, this.user, version, now)), + tap(({ payload }) => { this.dialogs.notifyInfo('Content updated successfully.'); - if (dto.version.value !== content.version.value) { - this.replaceContent(discardChanges(content, this.user, dto.version, now)); - } + this.replaceContent(payload, content.version); }), shareSubscribed(this.dialogs)); } - public patch(content: ContentDto, request: any, now?: DateTime): Observable { + public patch(content: ContentDto, request: any, now?: DateTime): Observable { return this.contentsService.patchContent(this.appName, this.schemaName, content.id, request, content.version).pipe( - tap(dto => { + mapVersioned((data, version) => updateData(content, data, this.user, version, now)), + tap(({ payload }) => { this.dialogs.notifyInfo('Content updated successfully.'); - if (dto.version.value !== content.version.value) { - this.replaceContent(updateData(content, dto.payload, this.user, dto.version, now)); - } + this.replaceContent(payload, content.version); }), shareSubscribed(this.dialogs)); } - private replaceContent(content: ContentDto) { - return this.next(s => { - const contents = s.contents.replaceBy('id', content); - const selectedContent = s.selectedContent && s.selectedContent.id === content.id ? content : s.selectedContent; + private replaceContent(content: ContentDto, oldVersion?: Version) { + if (!oldVersion || oldVersion.eq(content.version)) { + return this.next(s => { + const contents = s.contents.replaceBy('id', content); + const selectedContent = s.selectedContent && s.selectedContent.id === content.id ? content : s.selectedContent; - return { ...s, contents, selectedContent }; - }); + return { ...s, contents, selectedContent }; + }); + } } public goArchive(isArchive: boolean): Observable { diff --git a/src/Squidex/app/shared/state/plans.state.ts b/src/Squidex/app/shared/state/plans.state.ts index 6019c7c90..d1fcb0ba9 100644 --- a/src/Squidex/app/shared/state/plans.state.ts +++ b/src/Squidex/app/shared/state/plans.state.ts @@ -112,14 +112,14 @@ export class PlansState extends State { public change(planId: string): Observable { return this.plansService.putPlan(this.appName, { planId }, this.version).pipe( - tap(dto => { - if (dto.payload.redirectUri && dto.payload.redirectUri.length > 0) { - this.window.location.href = dto.payload.redirectUri; + tap(({ payload, version }) => { + if (payload.redirectUri && payload.redirectUri.length > 0) { + this.window.location.href = payload.redirectUri; } else { this.next(s => { const plans = s.plans.map(x => this.createPlan(x.plan, planId)); - return { ...s, plans, isOwner: true, version: dto.version }; + return { ...s, plans, isOwner: true, version }; }); } }), diff --git a/src/Squidex/app/shared/state/rule-events.state.ts b/src/Squidex/app/shared/state/rule-events.state.ts index dea86da12..b9bf61996 100644 --- a/src/Squidex/app/shared/state/rule-events.state.ts +++ b/src/Squidex/app/shared/state/rule-events.state.ts @@ -66,14 +66,14 @@ export class RuleEventsState extends State { return this.rulesService.getEvents(this.appName, this.snapshot.ruleEventsPager.pageSize, this.snapshot.ruleEventsPager.skip).pipe( - tap(dtos => { + tap(({ total, items }) => { if (isReload) { this.dialogs.notifyInfo('RuleEvents reloaded.'); } return this.next(s => { - const ruleEvents = ImmutableArray.of(dtos.items); - const ruleEventsPager = s.ruleEventsPager.setCount(dtos.total); + const ruleEvents = ImmutableArray.of(items); + const ruleEventsPager = s.ruleEventsPager.setCount(total); return { ...s, ruleEvents, ruleEventsPager, isLoaded: true }; }); diff --git a/src/Squidex/app/shared/state/schemas.state.ts b/src/Squidex/app/shared/state/schemas.state.ts index f4bb90346..6e2928ed5 100644 --- a/src/Squidex/app/shared/state/schemas.state.ts +++ b/src/Squidex/app/shared/state/schemas.state.ts @@ -93,7 +93,7 @@ export class SchemasState extends State { private readonly dialogs: DialogService, private readonly schemasService: SchemasService ) { - super({ schemas: ImmutableArray.empty(), categories: {} }); + super({ schemas: ImmutableArray.empty(), categories: buildCategories({}) }); } public select(idOrName: string | null): Observable { @@ -137,9 +137,9 @@ export class SchemasState extends State { public create(request: CreateSchemaDto, now?: DateTime): Observable { return this.schemasService.postSchema(this.appName, request, this.user, now || DateTime.now()).pipe( - tap(dto => { + tap(payload => { this.next(s => { - const schemas = s.schemas.push(dto).sortByStringAsc(x => x.displayName); + const schemas = s.schemas.push(payload).sortByStringAsc(x => x.displayName); return { ...s, schemas }; }); @@ -375,7 +375,7 @@ export class SchemasState extends State { } } -function buildCategories(categories: { [name: string]: boolean }, schemas: SchemasList) { +function buildCategories(categories: { [name: string]: boolean }, schemas?: SchemasList) { categories = { ...categories }; for (let category in categories) { @@ -385,10 +385,15 @@ function buildCategories(categories: { [name: string]: boolean }, schemas: Schem } } } - for (let schema of schemas.values) { - categories[schema.category || ''] = false; + + if (schemas) { + for (let schema of schemas.values) { + categories[schema.category || ''] = false; + } } + categories[''] = true; + return categories; } diff --git a/src/Squidex/app/shared/state/ui.state.ts b/src/Squidex/app/shared/state/ui.state.ts index 27c7ed597..b9e1402c8 100644 --- a/src/Squidex/app/shared/state/ui.state.ts +++ b/src/Squidex/app/shared/state/ui.state.ts @@ -55,15 +55,15 @@ export class UIState extends State { this.next(s => updateAppSettings(s, {})); this.uiService.getSettings(this.appName) - .subscribe(dtos => { - this.next(s => updateAppSettings(s, dtos)); + .subscribe(payload => { + this.next(s => updateAppSettings(s, payload)); }); } private loadCommon() { this.uiService.getCommonSettings() - .subscribe(dtos => { - this.next(s => updateCommonSettings(s, dtos)); + .subscribe(payload => { + this.next(s => updateCommonSettings(s, payload)); }); }