From 72d6259988f9160f762cdd6a29a0607aa2f1f776 Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Sat, 4 Dec 2021 17:38:58 +0100 Subject: [PATCH] Feature/resolve batching (#805) * Batch references. * Tests for resolvers. * Easier async code in tests * Tests fixes. --- .../guards/user-must-exist.guard.spec.ts | 42 ++-- .../administration/state/users.state.spec.ts | 42 ++-- frontend/app/features/content/declarations.ts | 3 + frontend/app/features/content/module.ts | 6 +- .../pages/content/content-page.component.ts | 4 +- .../shared/forms/assets-editor.component.ts | 26 ++- .../references/content-creator.component.ts | 4 +- .../reference-dropdown.component.html | 0 .../reference-dropdown.component.scss | 0 .../reference-dropdown.component.ts | 20 +- .../references-checkboxes.component.html | 0 .../references-checkboxes.component.scss | 0 .../references-checkboxes.component.ts | 0 .../references/references-editor.component.ts | 11 +- .../references/references-tag-converter.ts | 0 .../references/references-tags.component.html | 0 .../references/references-tags.component.scss | 0 .../references/references-tags.component.ts | 20 +- frontend/app/shared/declarations.ts | 3 - .../guards/app-must-exist.guard.spec.ts | 26 +-- .../guards/content-must-exist.guard.spec.ts | 38 +--- .../app/shared/guards/load-apps.guard.spec.ts | 10 +- .../guards/load-languages.guard.spec.ts | 10 +- .../shared/guards/load-schemas.guard.spec.ts | 10 +- .../must-be-authenticated.guard.spec.ts | 28 +-- .../must-be-not-authenticated.guard.spec.ts | 30 +-- .../guards/rule-must-exist.guard.spec.ts | 42 ++-- .../schema-must-exist-published.guard.spec.ts | 42 ++-- .../guards/schema-must-exist.guard.spec.ts | 22 +- ...schema-must-not-be-singleton.guard.spec.ts | 32 +-- .../app/shared/guards/unset-app.guard.spec.ts | 10 +- frontend/app/shared/internal.ts | 1 + frontend/app/shared/module.ts | 8 +- frontend/app/shared/state/apps.state.spec.ts | 55 ++--- .../shared/state/asset-uploader.state.spec.ts | 32 +-- .../app/shared/state/contents.forms.spec.ts | 4 +- frontend/app/shared/state/resolvers.spec.ts | 209 ++++++++++++++++++ frontend/app/shared/state/resolvers.ts | 206 +++++++++++++++++ frontend/app/shared/state/rules.state.spec.ts | 28 +-- .../app/shared/state/schemas.state.spec.ts | 44 ++-- .../app/shared/state/table-fields.spec.ts | 2 +- 41 files changed, 638 insertions(+), 432 deletions(-) rename frontend/app/{shared/components => features/content/shared}/references/reference-dropdown.component.html (100%) rename frontend/app/{shared/components => features/content/shared}/references/reference-dropdown.component.scss (100%) rename frontend/app/{shared/components => features/content/shared}/references/reference-dropdown.component.ts (86%) rename frontend/app/{shared/components => features/content/shared}/references/references-checkboxes.component.html (100%) rename frontend/app/{shared/components => features/content/shared}/references/references-checkboxes.component.scss (100%) rename frontend/app/{shared/components => features/content/shared}/references/references-checkboxes.component.ts (100%) rename frontend/app/{shared/components => features/content/shared}/references/references-tag-converter.ts (100%) rename frontend/app/{shared/components => features/content/shared}/references/references-tags.component.html (100%) rename frontend/app/{shared/components => features/content/shared}/references/references-tags.component.scss (100%) rename frontend/app/{shared/components => features/content/shared}/references/references-tags.component.ts (84%) create mode 100644 frontend/app/shared/state/resolvers.spec.ts create mode 100644 frontend/app/shared/state/resolvers.ts diff --git a/frontend/app/features/administration/guards/user-must-exist.guard.spec.ts b/frontend/app/features/administration/guards/user-must-exist.guard.spec.ts index f0b635398..10185f590 100644 --- a/frontend/app/features/administration/guards/user-must-exist.guard.spec.ts +++ b/frontend/app/features/administration/guards/user-must-exist.guard.spec.ts @@ -7,7 +7,7 @@ import { Router } from '@angular/router'; import { UserDto, UsersState } from '@app/features/administration/internal'; -import { of } from 'rxjs'; +import { firstValueFrom, of } from 'rxjs'; import { IMock, Mock, Times } from 'typemoq'; import { UserMustExistGuard } from './user-must-exist.guard'; @@ -22,86 +22,70 @@ describe('UserMustExistGuard', () => { userGuard = new UserMustExistGuard(usersState.object, router.object); }); - it('should load user and return true if found', () => { + it('should load user and return true if found', async () => { usersState.setup(x => x.select('123')) .returns(() => of({})); - let result: boolean; - const route: any = { params: { userId: '123', }, }; - userGuard.canActivate(route).subscribe(x => { - result = x; - }).unsubscribe(); + const result = await firstValueFrom(userGuard.canActivate(route)); - expect(result!).toBeTruthy(); + expect(result).toBeTruthy(); usersState.verify(x => x.select('123'), Times.once()); }); - it('should load user and return false if not found', () => { + it('should load user and return false if not found', async () => { usersState.setup(x => x.select('123')) .returns(() => of(null)); - let result: boolean; - const route: any = { params: { userId: '123', }, }; - userGuard.canActivate(route).subscribe(x => { - result = x; - }).unsubscribe(); + const result = await firstValueFrom(userGuard.canActivate(route)); - expect(result!).toBeFalsy(); + expect(result).toBeFalsy(); router.verify(x => x.navigate(['/404']), Times.once()); }); - it('should unset user if user id is undefined', () => { + it('should unset user if user id is undefined', async () => { usersState.setup(x => x.select(null)) .returns(() => of(null)); - let result: boolean; - const route: any = { params: { userId: undefined, }, }; - userGuard.canActivate(route).subscribe(x => { - result = x; - }).unsubscribe(); + const result = await firstValueFrom(userGuard.canActivate(route)); - expect(result!).toBeTruthy(); + expect(result).toBeTruthy(); usersState.verify(x => x.select(null), Times.once()); }); - it('should unset user if user id is ', () => { + it('should unset user if user id is ', async () => { usersState.setup(x => x.select(null)) .returns(() => of(null)); - let result: boolean; - const route: any = { params: { userId: 'new', }, }; - userGuard.canActivate(route).subscribe(x => { - result = x; - }).unsubscribe(); + const result = await firstValueFrom(userGuard.canActivate(route)); - expect(result!).toBeTruthy(); + expect(result).toBeTruthy(); usersState.verify(x => x.select(null), Times.once()); }); diff --git a/frontend/app/features/administration/state/users.state.spec.ts b/frontend/app/features/administration/state/users.state.spec.ts index aec4a2fa7..78ff14814 100644 --- a/frontend/app/features/administration/state/users.state.spec.ts +++ b/frontend/app/features/administration/state/users.state.spec.ts @@ -5,9 +5,9 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -import { UserDto, UsersDto, UsersService } from '@app/features/administration/internal'; +import { UsersDto, UsersService } from '@app/features/administration/internal'; import { DialogService } from '@app/shared'; -import { of, throwError } from 'rxjs'; +import { firstValueFrom, of, throwError } from 'rxjs'; import { onErrorResumeNext } from 'rxjs/operators'; import { IMock, It, Mock, Times } from 'typemoq'; import { createUser } from './../services/users.service.spec'; @@ -98,53 +98,37 @@ describe('UsersState', () => { usersState.load().subscribe(); }); - it('should return user on select and not load if already loaded', () => { - let selectedUser: UserDto; + it('should return user on select and not load if already loaded', async () => { + const userSelected = await firstValueFrom(usersState.select(user1.id)); - usersState.select(user1.id).subscribe(x => { - selectedUser = x!; - }); - - expect(selectedUser!).toEqual(user1); + expect(userSelected).toEqual(user1); expect(usersState.snapshot.selectedUser).toEqual(user1); }); - it('should return user on select and load if not loaded', () => { + it('should return user on select and load if not loaded', async () => { usersService.setup(x => x.getUser('id3')) .returns(() => of(newUser)); - let userSelected: UserDto; - - usersState.select('id3').subscribe(x => { - userSelected = x!; - }); + const userSelected = await firstValueFrom(usersState.select('id3')); expect(userSelected!).toEqual(newUser); expect(usersState.snapshot.selectedUser).toEqual(newUser); }); - it('should return null on select if unselecting user', () => { - let userSelected: UserDto; + it('should return null on select if unselecting user', async () => { + const userSelected = await firstValueFrom(usersState.select(null)); - usersState.select(null).subscribe(x => { - userSelected = x!; - }); - - expect(userSelected!).toBeNull(); + expect(userSelected).toBeNull(); expect(usersState.snapshot.selectedUser).toBeNull(); }); - it('should return null on select if user is not found', () => { + it('should return null on select if user is not found', async () => { usersService.setup(x => x.getUser('unknown')) .returns(() => throwError(() => 'Service Error')).verifiable(); - let userSelected: UserDto; - - usersState.select('unknown').pipe(onErrorResumeNext()).subscribe(x => { - userSelected = x!; - }).unsubscribe(); + const userSelected = await firstValueFrom(usersState.select('unknown')); - expect(userSelected!).toBeNull(); + expect(userSelected).toBeNull(); expect(usersState.snapshot.selectedUser).toBeNull(); }); diff --git a/frontend/app/features/content/declarations.ts b/frontend/app/features/content/declarations.ts index 57c4131e4..ffed01b58 100644 --- a/frontend/app/features/content/declarations.ts +++ b/frontend/app/features/content/declarations.ts @@ -35,5 +35,8 @@ export * from './shared/forms/stock-photo-editor.component'; export * from './shared/list/content.component'; export * from './shared/preview-button.component'; export * from './shared/references/content-creator.component'; +export * from './shared/references/reference-dropdown.component'; export * from './shared/references/reference-item.component'; +export * from './shared/references/references-checkboxes.component'; export * from './shared/references/references-editor.component'; +export * from './shared/references/references-tags.component'; diff --git a/frontend/app/features/content/module.ts b/frontend/app/features/content/module.ts index c145b0822..5747796f5 100644 --- a/frontend/app/features/content/module.ts +++ b/frontend/app/features/content/module.ts @@ -10,7 +10,7 @@ import { RouterModule, Routes } from '@angular/router'; import { CanDeactivateGuard, ContentMustExistGuard, LoadLanguagesGuard, LoadSchemasGuard, SchemaMustExistPublishedGuard, SchemaMustNotBeSingletonGuard, SqxFrameworkModule, SqxSharedModule } from '@app/shared'; import { ScrollingModule } from '@angular/cdk/scrolling'; import { ScrollingModule as ScrollingModuleExperimental } from '@angular/cdk-experimental/scrolling'; -import { ArrayEditorComponent, ArrayItemComponent, AssetsEditorComponent, CalendarPageComponent, CommentsPageComponent, ComponentComponent, ComponentSectionComponent, ContentComponent, ContentCreatorComponent, ContentEditorComponent, ContentEventComponent, ContentExtensionComponent, ContentFieldComponent, ContentHistoryPageComponent, ContentInspectionComponent, ContentPageComponent, ContentReferencesComponent, ContentSectionComponent, ContentsFiltersPageComponent, ContentsPageComponent, CustomViewEditorComponent, DueTimeSelectorComponent, FieldCopyButtonComponent, FieldEditorComponent, FieldLanguagesComponent, IFrameEditorComponent, PreviewButtonComponent, ReferenceItemComponent, ReferencesEditorComponent, SchemasPageComponent, SidebarPageComponent, StockPhotoEditorComponent } from './declarations'; +import { ArrayEditorComponent, ArrayItemComponent, AssetsEditorComponent, CalendarPageComponent, CommentsPageComponent, ComponentComponent, ComponentSectionComponent, ContentComponent, ContentCreatorComponent, ContentEditorComponent, ContentEventComponent, ContentExtensionComponent, ContentFieldComponent, ContentHistoryPageComponent, ContentInspectionComponent, ContentPageComponent, ContentReferencesComponent, ContentSectionComponent, ContentsFiltersPageComponent, ContentsPageComponent, CustomViewEditorComponent, DueTimeSelectorComponent, FieldCopyButtonComponent, FieldEditorComponent, FieldLanguagesComponent, IFrameEditorComponent, PreviewButtonComponent, ReferenceDropdownComponent, ReferenceItemComponent, ReferencesCheckboxesComponent, ReferencesEditorComponent, ReferencesTagsComponent, SchemasPageComponent, SidebarPageComponent, StockPhotoEditorComponent } from './declarations'; const routes: Routes = [ { @@ -121,8 +121,12 @@ const routes: Routes = [ FieldLanguagesComponent, IFrameEditorComponent, PreviewButtonComponent, + ReferenceDropdownComponent, ReferenceItemComponent, + ReferencesCheckboxesComponent, ReferencesEditorComponent, + ReferencesEditorComponent, + ReferencesTagsComponent, SchemasPageComponent, SidebarPageComponent, StockPhotoEditorComponent, diff --git a/frontend/app/features/content/pages/content/content-page.component.ts b/frontend/app/features/content/pages/content/content-page.component.ts index af156e607..8bb646064 100644 --- a/frontend/app/features/content/pages/content/content-page.component.ts +++ b/frontend/app/features/content/pages/content/content-page.component.ts @@ -7,7 +7,7 @@ import { Component, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import { ApiUrlConfig, AppLanguageDto, AppsState, AuthService, AutoSaveKey, AutoSaveService, CanComponentDeactivate, ContentDto, ContentsState, defined, DialogService, EditContentForm, fadeAnimation, LanguagesState, ModalModel, ResourceOwner, SchemaDto, SchemasState, TempService, ToolbarService, Types, Version } from '@app/shared'; +import { ApiUrlConfig, AppLanguageDto, AppsState, AuthService, AutoSaveKey, AutoSaveService, CanComponentDeactivate, ContentDto, ResolveContents, ContentsState, defined, DialogService, EditContentForm, fadeAnimation, LanguagesState, ModalModel, ResourceOwner, SchemaDto, SchemasState, TempService, ToolbarService, Types, Version, ResolveAssets } from '@app/shared'; import { Observable, of } from 'rxjs'; import { filter, map, tap } from 'rxjs/operators'; @@ -19,6 +19,8 @@ import { filter, map, tap } from 'rxjs/operators'; fadeAnimation, ], providers: [ + ResolveAssets, + ResolveContents, ToolbarService, ], }) diff --git a/frontend/app/features/content/shared/forms/assets-editor.component.ts b/frontend/app/features/content/shared/forms/assets-editor.component.ts index 5cd505cbd..5a3235cd8 100644 --- a/frontend/app/features/content/shared/forms/assets-editor.component.ts +++ b/frontend/app/features/content/shared/forms/assets-editor.component.ts @@ -8,7 +8,7 @@ import { CdkDragDrop } from '@angular/cdk/drag-drop'; import { ChangeDetectionStrategy, ChangeDetectorRef, Component, forwardRef, Input, OnInit } from '@angular/core'; import { NG_VALUE_ACCESSOR } from '@angular/forms'; -import { AppsState, AssetDto, AssetsService, DialogModel, LocalStoreService, MessageBus, Settings, sorted, StatefulControlComponent, Types } from '@app/shared'; +import { AssetDto, DialogModel, LocalStoreService, MessageBus, ResolveAssets, Settings, sorted, StatefulControlComponent, Types } from '@app/shared'; export const SQX_ASSETS_EDITOR_CONTROL_VALUE_ACCESSOR: any = { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => AssetsEditorComponent), multi: true, @@ -57,8 +57,7 @@ export class AssetsEditorComponent extends StatefulControlComponent x.id))) { const assetIds: string[] = obj; - this.assetsService.getAssets(this.appsState.appName, { ids: obj }) - .subscribe(dtos => { - this.setAssets(assetIds.map(id => dtos.items.find(x => x.id === id)!).filter(a => !!a)); - - if (this.snapshot.assets.length !== assetIds.length) { - this.updateValue(); - } - }, () => { - this.setAssets([]); + this.assetsResolver.resolveMany(obj) + .subscribe({ + next: ({ items }) => { + this.setAssets(items); + + if (this.snapshot.assets.length !== assetIds.length) { + this.updateValue(); + } + }, + error: () => { + this.setAssets([]); + }, }); } } else { diff --git a/frontend/app/features/content/shared/references/content-creator.component.ts b/frontend/app/features/content/shared/references/content-creator.component.ts index 08b6ef538..43c2b09fc 100644 --- a/frontend/app/features/content/shared/references/content-creator.component.ts +++ b/frontend/app/features/content/shared/references/content-creator.component.ts @@ -6,13 +6,15 @@ */ import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; -import { AppLanguageDto, ComponentContentsState, ContentDto, EditContentForm, ResourceOwner, SchemaDto, SchemasState } from '@app/shared'; +import { AppLanguageDto, ComponentContentsState, ContentDto, EditContentForm, ResolveAssets, ResolveContents, ResourceOwner, SchemaDto, SchemasState } from '@app/shared'; @Component({ selector: 'sqx-content-creator[formContext][language][languages]', styleUrls: ['./content-creator.component.scss'], templateUrl: './content-creator.component.html', providers: [ + ResolveAssets, + ResolveContents, ComponentContentsState, ], }) diff --git a/frontend/app/shared/components/references/reference-dropdown.component.html b/frontend/app/features/content/shared/references/reference-dropdown.component.html similarity index 100% rename from frontend/app/shared/components/references/reference-dropdown.component.html rename to frontend/app/features/content/shared/references/reference-dropdown.component.html diff --git a/frontend/app/shared/components/references/reference-dropdown.component.scss b/frontend/app/features/content/shared/references/reference-dropdown.component.scss similarity index 100% rename from frontend/app/shared/components/references/reference-dropdown.component.scss rename to frontend/app/features/content/shared/references/reference-dropdown.component.scss diff --git a/frontend/app/shared/components/references/reference-dropdown.component.ts b/frontend/app/features/content/shared/references/reference-dropdown.component.ts similarity index 86% rename from frontend/app/shared/components/references/reference-dropdown.component.ts rename to frontend/app/features/content/shared/references/reference-dropdown.component.ts index 2cd715a5a..0b12dfd35 100644 --- a/frontend/app/shared/components/references/reference-dropdown.component.ts +++ b/frontend/app/features/content/shared/references/reference-dropdown.component.ts @@ -8,7 +8,7 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, forwardRef, Input, OnChanges, SimpleChanges } from '@angular/core'; import { FormControl, NG_VALUE_ACCESSOR } from '@angular/forms'; import { ContentsDto } from '@app/shared'; -import { AppsState, ContentDto, ContentsService, getContentValue, LanguageDto, LocalizerService, StatefulControlComponent, Types, UIOptions, value$ } from '@app/shared/internal'; +import { ContentDto, ResolveContents, getContentValue, LanguageDto, LocalizerService, StatefulControlComponent, Types, value$ } from '@app/shared/internal'; import { Observable } from 'rxjs'; export const SQX_REFERENCE_DROPDOWN_CONTROL_VALUE_ACCESSOR: any = { @@ -37,7 +37,6 @@ const NO_EMIT = { emitEvent: false }; changeDetection: ChangeDetectionStrategy.OnPush, }) export class ReferenceDropdownComponent extends StatefulControlComponent | string> implements OnChanges { - private readonly itemCount: number; private readonly contents: ContentDto[] = []; private isOpenedBefore = false; private isLoadingFailed = false; @@ -65,17 +64,14 @@ export class ReferenceDropdownComponent extends StatefulControlComponent { @@ -133,14 +129,14 @@ export class ReferenceDropdownComponent extends StatefulControlComponent x.id === id); if (id && isNewId) { - this.loadMore(this.contentsService.getAllContents(this.appsState.appName, { ids: [id] })); + this.loadMore(this.contentsResolver.resolveMany([id])); } this.control.setValue(id, NO_EMIT); @@ -149,12 +145,12 @@ export class ReferenceDropdownComponent extends StatefulControlComponent) { observable .subscribe({ - next: ({ items: newContents }) => { - if (newContents.length === 0) { + next: ({ items }) => { + if (items.length === 0) { return; } - for (const content of newContents) { + for (const content of items) { const index = this.contents.findIndex(x => x.id === content.id); if (index >= 0) { diff --git a/frontend/app/shared/components/references/references-checkboxes.component.html b/frontend/app/features/content/shared/references/references-checkboxes.component.html similarity index 100% rename from frontend/app/shared/components/references/references-checkboxes.component.html rename to frontend/app/features/content/shared/references/references-checkboxes.component.html diff --git a/frontend/app/shared/components/references/references-checkboxes.component.scss b/frontend/app/features/content/shared/references/references-checkboxes.component.scss similarity index 100% rename from frontend/app/shared/components/references/references-checkboxes.component.scss rename to frontend/app/features/content/shared/references/references-checkboxes.component.scss diff --git a/frontend/app/shared/components/references/references-checkboxes.component.ts b/frontend/app/features/content/shared/references/references-checkboxes.component.ts similarity index 100% rename from frontend/app/shared/components/references/references-checkboxes.component.ts rename to frontend/app/features/content/shared/references/references-checkboxes.component.ts diff --git a/frontend/app/features/content/shared/references/references-editor.component.ts b/frontend/app/features/content/shared/references/references-editor.component.ts index 1da6f07dd..472f2ad68 100644 --- a/frontend/app/features/content/shared/references/references-editor.component.ts +++ b/frontend/app/features/content/shared/references/references-editor.component.ts @@ -8,7 +8,7 @@ import { CdkDragDrop } from '@angular/cdk/drag-drop'; import { ChangeDetectionStrategy, ChangeDetectorRef, Component, forwardRef, Input } from '@angular/core'; import { NG_VALUE_ACCESSOR } from '@angular/forms'; -import { AppLanguageDto, AppsState, ContentDto, ContentsService, DialogModel, sorted, StatefulControlComponent, Types } from '@app/shared'; +import { AppLanguageDto, ContentDto, ResolveContents, DialogModel, sorted, StatefulControlComponent, Types } from '@app/shared'; export const SQX_REFERENCES_EDITOR_CONTROL_VALUE_ACCESSOR: any = { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => ReferencesEditorComponent), multi: true, @@ -58,8 +58,7 @@ export class ReferencesEditorComponent extends StatefulControlComponent x.id))) { const contentIds: string[] = obj; - this.contentsService.getAllContents(this.appsState.appName, { ids: contentIds }) + this.contentsResolver.resolveMany(contentIds) .subscribe({ - next: dtos => { - this.setContentItems(contentIds.map(id => dtos.items.find(c => c.id === id)!).filter(r => !!r)); + next: ({ items }) => { + this.setContentItems(contentIds.map(id => items.find(c => c.id === id)!).filter(r => !!r)); if (this.snapshot.contentItems.length !== contentIds.length) { this.updateValue(); diff --git a/frontend/app/shared/components/references/references-tag-converter.ts b/frontend/app/features/content/shared/references/references-tag-converter.ts similarity index 100% rename from frontend/app/shared/components/references/references-tag-converter.ts rename to frontend/app/features/content/shared/references/references-tag-converter.ts diff --git a/frontend/app/shared/components/references/references-tags.component.html b/frontend/app/features/content/shared/references/references-tags.component.html similarity index 100% rename from frontend/app/shared/components/references/references-tags.component.html rename to frontend/app/features/content/shared/references/references-tags.component.html diff --git a/frontend/app/shared/components/references/references-tags.component.scss b/frontend/app/features/content/shared/references/references-tags.component.scss similarity index 100% rename from frontend/app/shared/components/references/references-tags.component.scss rename to frontend/app/features/content/shared/references/references-tags.component.scss diff --git a/frontend/app/shared/components/references/references-tags.component.ts b/frontend/app/features/content/shared/references/references-tags.component.ts similarity index 84% rename from frontend/app/shared/components/references/references-tags.component.ts rename to frontend/app/features/content/shared/references/references-tags.component.ts index ee0ca9b55..5e492f3cf 100644 --- a/frontend/app/shared/components/references/references-tags.component.ts +++ b/frontend/app/features/content/shared/references/references-tags.component.ts @@ -8,7 +8,7 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, forwardRef, Input, OnChanges, SimpleChanges } from '@angular/core'; import { FormControl, NG_VALUE_ACCESSOR } from '@angular/forms'; import { Types } from '@app/framework'; -import { AppsState, ContentDto, ContentsDto, ContentsService, LanguageDto, LocalizerService, StatefulControlComponent, UIOptions } from '@app/shared/internal'; +import { ContentDto, ContentsDto, ResolveContents, LanguageDto, LocalizerService, StatefulControlComponent } from '@app/shared/internal'; import { Observable } from 'rxjs'; import { ReferencesTagsConverter } from './references-tag-converter'; @@ -33,7 +33,6 @@ const NO_EMIT = { emitEvent: false }; changeDetection: ChangeDetectionStrategy.OnPush, }) export class ReferencesTagsComponent extends StatefulControlComponent> implements OnChanges { - private readonly itemCount: number; private readonly contents: ContentDto[] = []; private isOpenedBefore = false; private isLoadingFailed = false; @@ -58,17 +57,14 @@ export class ReferencesTagsComponent extends StatefulControlComponent { @@ -108,7 +104,7 @@ export class ReferencesTagsComponent extends StatefulControlComponent) { @@ -123,7 +119,7 @@ export class ReferencesTagsComponent extends StatefulControlComponent !this.contents?.find(y => y.id === x)); if (newIds && newIds.length > 0) { - this.loadMore(this.contentsService.getAllContents(this.appsState.appName, { ids: newIds })); + this.loadMore(this.contentsResolver.resolveMany(newIds)); } this.control.setValue(ids, NO_EMIT); @@ -132,12 +128,12 @@ export class ReferencesTagsComponent extends StatefulControlComponent) { observable .subscribe({ - next: ({ items: newContents }) => { - if (newContents.length === 0) { + next: ({ items }) => { + if (items.length === 0) { return; } - for (const content of newContents) { + for (const content of items) { const index = this.contents.findIndex(x => x.id === content.id); if (index >= 0) { diff --git a/frontend/app/shared/declarations.ts b/frontend/app/shared/declarations.ts index f5785667a..4c7feefc6 100644 --- a/frontend/app/shared/declarations.ts +++ b/frontend/app/shared/declarations.ts @@ -41,10 +41,7 @@ export * from './components/notifo.component'; export * from './components/pipes'; export * from './components/references/content-selector-item.component'; export * from './components/references/content-selector.component'; -export * from './components/references/reference-dropdown.component'; export * from './components/references/reference-input.component'; -export * from './components/references/references-checkboxes.component'; -export * from './components/references/references-tags.component'; export * from './components/schema-category.component'; export * from './components/search/queries/filter-comparison.component'; export * from './components/search/queries/filter-logical.component'; diff --git a/frontend/app/shared/guards/app-must-exist.guard.spec.ts b/frontend/app/shared/guards/app-must-exist.guard.spec.ts index 6798b837f..e427d58fe 100644 --- a/frontend/app/shared/guards/app-must-exist.guard.spec.ts +++ b/frontend/app/shared/guards/app-must-exist.guard.spec.ts @@ -7,7 +7,7 @@ import { Router } from '@angular/router'; import { AppsState } from '@app/shared'; -import { of } from 'rxjs'; +import { firstValueFrom, of } from 'rxjs'; import { IMock, Mock, Times } from 'typemoq'; import { AppMustExistGuard } from './app-must-exist.guard'; @@ -30,33 +30,23 @@ describe('AppMustExistGuard', () => { appGuard = new AppMustExistGuard(appsState.object, router.object); }); - it('should navigate to 404 page if app is not found', () => { + it('should navigate to 404 page if app is not found', async () => { appsState.setup(x => x.select('my-app')) .returns(() => of(null)); - let result: boolean; + const result = await firstValueFrom(appGuard.canActivate(route)); - appGuard.canActivate(route).subscribe(x => { - result = x; - }); + expect(result).toBeFalsy(); - expect(result!).toBeFalsy(); - - appsState.verify(x => x.select('my-app'), Times.once()); + router.verify(x => x.navigate(['/404']), Times.once()); }); - it('should return true if app is found', () => { + it('should return true if app is found', async () => { appsState.setup(x => x.select('my-app')) .returns(() => of({})); - let result: boolean; - - appGuard.canActivate(route).subscribe(x => { - result = x; - }); - - expect(result!).toBeTruthy(); + const result = await firstValueFrom(appGuard.canActivate(route)); - // router.verify(x => x.navigate(['/404']), Times.once()); + expect(result).toBeTruthy(); }); }); diff --git a/frontend/app/shared/guards/content-must-exist.guard.spec.ts b/frontend/app/shared/guards/content-must-exist.guard.spec.ts index 407a5773a..4b64b70b3 100644 --- a/frontend/app/shared/guards/content-must-exist.guard.spec.ts +++ b/frontend/app/shared/guards/content-must-exist.guard.spec.ts @@ -7,7 +7,7 @@ import { Router } from '@angular/router'; import { ContentDto, ContentsState } from '@app/shared/internal'; -import { of } from 'rxjs'; +import { firstValueFrom, of } from 'rxjs'; import { IMock, It, Mock, Times } from 'typemoq'; import { ContentMustExistGuard } from './content-must-exist.guard'; @@ -22,84 +22,68 @@ describe('ContentMustExistGuard', () => { contentGuard = new ContentMustExistGuard(contentsState.object, router.object); }); - it('should load content and return true if found', () => { + it('should load content and return true if found', async () => { contentsState.setup(x => x.select('123')) .returns(() => of({})); - let result: boolean; - const route: any = { params: { contentId: '123', }, }; - contentGuard.canActivate(route).subscribe(x => { - result = x; - }).unsubscribe(); + const result = await firstValueFrom(contentGuard.canActivate(route)); - expect(result!).toBeTruthy(); + expect(result).toBeTruthy(); router.verify(x => x.navigate(It.isAny()), Times.never()); }); - it('should load content and return false if not found', () => { + it('should load content and return false if not found', async () => { contentsState.setup(x => x.select('123')) .returns(() => of(null)); - let result: boolean; - const route: any = { params: { contentId: '123', }, }; - contentGuard.canActivate(route).subscribe(x => { - result = x; - }).unsubscribe(); + const result = await firstValueFrom(contentGuard.canActivate(route)); - expect(result!).toBeFalsy(); + expect(result).toBeFalsy(); router.verify(x => x.navigate(['/404']), Times.once()); }); - it('should unset content if content id is undefined', () => { + it('should unset content if content id is undefined', async () => { contentsState.setup(x => x.select(null)) .returns(() => of(null)); - let result: boolean; - const route: any = { params: { contentId: undefined, }, }; - contentGuard.canActivate(route).subscribe(x => { - result = x; - }).unsubscribe(); + const result = await firstValueFrom(contentGuard.canActivate(route)); expect(result!).toBeTruthy(); contentsState.verify(x => x.select(null), Times.once()); }); - it('should unset content if content id is ', () => { + it('should unset content if content id is ', async () => { contentsState.setup(x => x.select(null)) .returns(() => of(null)); - let result: boolean; - const route: any = { params: { contentId: 'new', }, }; - contentGuard.canActivate(route).subscribe(x => { - result = x; - }).unsubscribe(); + const result = await firstValueFrom(contentGuard.canActivate(route)); expect(result!).toBeTruthy(); diff --git a/frontend/app/shared/guards/load-apps.guard.spec.ts b/frontend/app/shared/guards/load-apps.guard.spec.ts index a24eb8c84..d713c9293 100644 --- a/frontend/app/shared/guards/load-apps.guard.spec.ts +++ b/frontend/app/shared/guards/load-apps.guard.spec.ts @@ -6,7 +6,7 @@ */ import { AppsState } from '@app/shared'; -import { of } from 'rxjs'; +import { firstValueFrom, of } from 'rxjs'; import { IMock, Mock, Times } from 'typemoq'; import { LoadAppsGuard } from './load-apps.guard'; @@ -19,15 +19,11 @@ describe('LoadAppsGuard', () => { appGuard = new LoadAppsGuard(appsState.object); }); - it('should load apps', () => { + it('should load apps', async () => { appsState.setup(x => x.load()) .returns(() => of(null)); - let result = false; - - appGuard.canActivate().subscribe(value => { - result = value; - }); + const result = await firstValueFrom(appGuard.canActivate()); expect(result).toBeTruthy(); diff --git a/frontend/app/shared/guards/load-languages.guard.spec.ts b/frontend/app/shared/guards/load-languages.guard.spec.ts index 3b751faee..45c5118c5 100644 --- a/frontend/app/shared/guards/load-languages.guard.spec.ts +++ b/frontend/app/shared/guards/load-languages.guard.spec.ts @@ -6,7 +6,7 @@ */ import { LanguagesState } from '@app/shared'; -import { of } from 'rxjs'; +import { firstValueFrom, of } from 'rxjs'; import { IMock, Mock, Times } from 'typemoq'; import { LoadLanguagesGuard } from './load-languages.guard'; @@ -19,15 +19,11 @@ describe('LoadLanguagesGuard', () => { languageGuard = new LoadLanguagesGuard(languagesState.object); }); - it('should load languages', () => { + it('should load languages', async () => { languagesState.setup(x => x.load()) .returns(() => of(null)); - let result = false; - - languageGuard.canActivate().subscribe(value => { - result = value; - }); + const result = await firstValueFrom(languageGuard.canActivate()); expect(result).toBeTruthy(); diff --git a/frontend/app/shared/guards/load-schemas.guard.spec.ts b/frontend/app/shared/guards/load-schemas.guard.spec.ts index f4ef7b135..69d66a0f9 100644 --- a/frontend/app/shared/guards/load-schemas.guard.spec.ts +++ b/frontend/app/shared/guards/load-schemas.guard.spec.ts @@ -6,7 +6,7 @@ */ import { SchemasState } from '@app/shared'; -import { of } from 'rxjs'; +import { firstValueFrom, of } from 'rxjs'; import { IMock, Mock, Times } from 'typemoq'; import { LoadSchemasGuard } from './load-schemas.guard'; @@ -19,15 +19,11 @@ describe('LoadSchemasGuard', () => { schemaGuard = new LoadSchemasGuard(schemasState.object); }); - it('should load schemas', () => { + it('should load schemas', async () => { schemasState.setup(x => x.load()) .returns(() => of(null)); - let result = false; - - schemaGuard.canActivate().subscribe(value => { - result = value; - }); + const result = await firstValueFrom(schemaGuard.canActivate()); expect(result).toBeTruthy(); diff --git a/frontend/app/shared/guards/must-be-authenticated.guard.spec.ts b/frontend/app/shared/guards/must-be-authenticated.guard.spec.ts index f347695dc..30e335c2c 100644 --- a/frontend/app/shared/guards/must-be-authenticated.guard.spec.ts +++ b/frontend/app/shared/guards/must-be-authenticated.guard.spec.ts @@ -7,7 +7,7 @@ import { Router } from '@angular/router'; import { AuthService, UIOptions } from '@app/shared'; -import { of } from 'rxjs'; +import { firstValueFrom, of } from 'rxjs'; import { IMock, It, Mock, Times } from 'typemoq'; import { MustBeAuthenticatedGuard } from './must-be-authenticated.guard'; @@ -23,51 +23,39 @@ describe('MustBeAuthenticatedGuard', () => { authService = Mock.ofType(); }); - it('should navigate to default page if not authenticated', () => { + it('should navigate to default page if not authenticated', async () => { const authGuard = new MustBeAuthenticatedGuard(uiOptions, authService.object, router.object); authService.setup(x => x.userChanges) .returns(() => of(null)); - let result: boolean; + const result = await firstValueFrom(authGuard.canActivate()); - authGuard.canActivate().subscribe(x => { - result = x; - }); - - expect(result!).toBeFalsy(); + expect(result).toBeFalsy(); router.verify(x => x.navigate(['']), Times.once()); }); - it('should return true if authenticated', () => { + it('should return true if authenticated', async () => { const authGuard = new MustBeAuthenticatedGuard(uiOptions, authService.object, router.object); authService.setup(x => x.userChanges) .returns(() => of({})); - let result: boolean; - - authGuard.canActivate().subscribe(x => { - result = x; - }); + const result = await firstValueFrom(authGuard.canActivate()); expect(result!).toBeTruthy(); router.verify(x => x.navigate(It.isAny()), Times.never()); }); - it('should login redirect if redirect enabled', () => { + it('should login redirect if redirect enabled', async () => { const authGuard = new MustBeAuthenticatedGuard(uiOptionsRedirect, authService.object, router.object); authService.setup(x => x.userChanges) .returns(() => of(null)); - let result: boolean; - - authGuard.canActivate().subscribe(x => { - result = x; - }); + const result = await firstValueFrom(authGuard.canActivate()); expect(result!).toBeFalsy(); diff --git a/frontend/app/shared/guards/must-be-not-authenticated.guard.spec.ts b/frontend/app/shared/guards/must-be-not-authenticated.guard.spec.ts index 85062c741..2d21d08e9 100644 --- a/frontend/app/shared/guards/must-be-not-authenticated.guard.spec.ts +++ b/frontend/app/shared/guards/must-be-not-authenticated.guard.spec.ts @@ -7,7 +7,7 @@ import { Router } from '@angular/router'; import { AuthService, UIOptions } from '@app/shared'; -import { of } from 'rxjs'; +import { firstValueFrom, of } from 'rxjs'; import { IMock, It, Mock, Times } from 'typemoq'; import { MustBeNotAuthenticatedGuard } from './must-be-not-authenticated.guard'; @@ -23,53 +23,41 @@ describe('MustBeNotAuthenticatedGuard', () => { authService = Mock.ofType(); }); - it('should navigate to app page if authenticated', () => { + it('should navigate to app page if authenticated', async () => { const authGuard = new MustBeNotAuthenticatedGuard(uiOptions, authService.object, router.object); authService.setup(x => x.userChanges) .returns(() => of({})); - let result: boolean; - - authGuard.canActivate().subscribe(x => { - result = x; - }); + const result = await firstValueFrom(authGuard.canActivate()); expect(result!).toBeFalsy(); router.verify(x => x.navigate(['app']), Times.once()); }); - it('should return true if not authenticated', () => { + it('should return true if not authenticated', async () => { const authGuard = new MustBeNotAuthenticatedGuard(uiOptions, authService.object, router.object); authService.setup(x => x.userChanges) .returns(() => of(null)); - let result: boolean; - - authGuard.canActivate().subscribe(x => { - result = x; - }); + const result = await firstValueFrom(authGuard.canActivate()); - expect(result!).toBeTruthy(); + expect(result).toBeTruthy(); router.verify(x => x.navigate(It.isAny()), Times.never()); }); - it('should login redirect and return false if redirect enabled', () => { + it('should login redirect and return false if redirect enabled', async () => { const authGuard = new MustBeNotAuthenticatedGuard(uiOptionsRedirect, authService.object, router.object); authService.setup(x => x.userChanges) .returns(() => of(null)); - let result: boolean; + const result = await firstValueFrom(authGuard.canActivate()); - authGuard.canActivate().subscribe(x => { - result = x; - }); - - expect(result!).toBeFalsy(); + expect(result).toBeFalsy(); authService.verify(x => x.loginRedirect(), Times.once()); }); diff --git a/frontend/app/shared/guards/rule-must-exist.guard.spec.ts b/frontend/app/shared/guards/rule-must-exist.guard.spec.ts index 7f5494416..a7ebc947a 100644 --- a/frontend/app/shared/guards/rule-must-exist.guard.spec.ts +++ b/frontend/app/shared/guards/rule-must-exist.guard.spec.ts @@ -7,7 +7,7 @@ import { Router } from '@angular/router'; import { RuleDto, RulesState } from '@app/shared/internal'; -import { of } from 'rxjs'; +import { firstValueFrom, of } from 'rxjs'; import { IMock, It, Mock, Times } from 'typemoq'; import { RuleMustExistGuard } from './rule-must-exist.guard'; @@ -22,86 +22,70 @@ describe('RuleMustExistGuard', () => { ruleGuard = new RuleMustExistGuard(rulesState.object, router.object); }); - it('should load rule and return true if found', () => { + it('should load rule and return true if found', async () => { rulesState.setup(x => x.select('123')) .returns(() => of({})); - let result: boolean; - const route: any = { params: { ruleId: '123', }, }; - ruleGuard.canActivate(route).subscribe(x => { - result = x; - }).unsubscribe(); + const result = await firstValueFrom(ruleGuard.canActivate(route)); - expect(result!).toBeTruthy(); + expect(result).toBeTruthy(); router.verify(x => x.navigate(It.isAny()), Times.never()); }); - it('should load rule and return false if not found', () => { + it('should load rule and return false if not found', async () => { rulesState.setup(x => x.select('123')) .returns(() => of(null)); - let result: boolean; - const route: any = { params: { ruleId: '123', }, }; - ruleGuard.canActivate(route).subscribe(x => { - result = x; - }).unsubscribe(); + const result = await firstValueFrom(ruleGuard.canActivate(route)); - expect(result!).toBeFalsy(); + expect(result).toBeFalsy(); router.verify(x => x.navigate(['/404']), Times.once()); }); - it('should unset rule if rule id is undefined', () => { + it('should unset rule if rule id is undefined', async () => { rulesState.setup(x => x.select(null)) .returns(() => of(null)); - let result: boolean; - const route: any = { params: { ruleId: undefined, }, }; - ruleGuard.canActivate(route).subscribe(x => { - result = x; - }).unsubscribe(); + const result = await firstValueFrom(ruleGuard.canActivate(route)); - expect(result!).toBeTruthy(); + expect(result).toBeTruthy(); rulesState.verify(x => x.select(null), Times.once()); }); - it('should unset rule if rule id is ', () => { + it('should unset rule if rule id is ', async () => { rulesState.setup(x => x.select(null)) .returns(() => of(null)); - let result: boolean; - const route: any = { params: { ruleId: 'new', }, }; - ruleGuard.canActivate(route).subscribe(x => { - result = x; - }).unsubscribe(); + const result = await firstValueFrom(ruleGuard.canActivate(route)); - expect(result!).toBeTruthy(); + expect(result).toBeTruthy(); rulesState.verify(x => x.select(null), Times.once()); }); diff --git a/frontend/app/shared/guards/schema-must-exist-published.guard.spec.ts b/frontend/app/shared/guards/schema-must-exist-published.guard.spec.ts index ab783869b..3e75230df 100644 --- a/frontend/app/shared/guards/schema-must-exist-published.guard.spec.ts +++ b/frontend/app/shared/guards/schema-must-exist-published.guard.spec.ts @@ -7,7 +7,7 @@ import { Router } from '@angular/router'; import { SchemaDto, SchemasState } from '@app/shared/internal'; -import { of } from 'rxjs'; +import { firstValueFrom, of } from 'rxjs'; import { IMock, It, Mock, Times } from 'typemoq'; import { SchemaMustExistPublishedGuard } from './schema-must-exist-published.guard'; @@ -28,62 +28,46 @@ describe('SchemaMustExistPublishedGuard', () => { schemaGuard = new SchemaMustExistPublishedGuard(schemasState.object, router.object); }); - it('should load schema and return true if published', () => { + it('should load schema and return true if published', async () => { schemasState.setup(x => x.select('123')) .returns(() => of({ isPublished: true })); - let result: boolean; + const result = await firstValueFrom(schemaGuard.canActivate(route)); - schemaGuard.canActivate(route).subscribe(x => { - result = x; - }).unsubscribe(); - - expect(result!).toBeTruthy(); + expect(result).toBeTruthy(); router.verify(x => x.navigate(It.isAny()), Times.never()); }); - it('should load schema and return false if component', () => { + it('should load schema and return false if component', async () => { schemasState.setup(x => x.select('123')) .returns(() => of({ isPublished: true, type: 'Component' })); - let result: boolean; - - schemaGuard.canActivate(route).subscribe(x => { - result = x; - }).unsubscribe(); + const result = await firstValueFrom(schemaGuard.canActivate(route)); - expect(result!).toBeFalsy(); + expect(result).toBeFalsy(); router.verify(x => x.navigate(['/404']), Times.once()); }); - it('should load schema and return false if not found', () => { + it('should load schema and return false if not found', async () => { schemasState.setup(x => x.select('123')) .returns(() => of(null)); - let result: boolean; + const result = await firstValueFrom(schemaGuard.canActivate(route)); - schemaGuard.canActivate(route).subscribe(x => { - result = x; - }).unsubscribe(); - - expect(result!).toBeFalsy(); + expect(result).toBeFalsy(); router.verify(x => x.navigate(['/404']), Times.once()); }); - it('should load schema and return false if not published', () => { + it('should load schema and return false if not published', async () => { schemasState.setup(x => x.select('123')) .returns(() => of({ isPublished: false, type: 'Default' })); - let result: boolean; - - schemaGuard.canActivate(route).subscribe(x => { - result = x; - }).unsubscribe(); + const result = await firstValueFrom(schemaGuard.canActivate(route)); - expect(result!).toBeFalsy(); + expect(result).toBeFalsy(); router.verify(x => x.navigate(['/404']), Times.once()); }); diff --git a/frontend/app/shared/guards/schema-must-exist.guard.spec.ts b/frontend/app/shared/guards/schema-must-exist.guard.spec.ts index 0eb4e4b49..1ce3561be 100644 --- a/frontend/app/shared/guards/schema-must-exist.guard.spec.ts +++ b/frontend/app/shared/guards/schema-must-exist.guard.spec.ts @@ -7,7 +7,7 @@ import { Router } from '@angular/router'; import { SchemaDto, SchemasState } from '@app/shared/internal'; -import { of } from 'rxjs'; +import { firstValueFrom, of } from 'rxjs'; import { IMock, Mock, Times } from 'typemoq'; import { SchemaMustExistGuard } from './schema-must-exist.guard'; @@ -28,30 +28,22 @@ describe('SchemaMustExistGuard', () => { schemaGuard = new SchemaMustExistGuard(schemasState.object, router.object); }); - it('should load schema and return true if found', () => { + it('should load schema and return true if found', async () => { schemasState.setup(x => x.select('123')) .returns(() => of({})); - let result: boolean; + const result = await firstValueFrom(schemaGuard.canActivate(route)); - schemaGuard.canActivate(route).subscribe(x => { - result = x; - }).unsubscribe(); - - expect(result!).toBeTruthy(); + expect(result).toBeTruthy(); }); - it('should load schema and return false if not found', () => { + it('should load schema and return false if not found', async () => { schemasState.setup(x => x.select('123')) .returns(() => of(null)); - let result: boolean; - - schemaGuard.canActivate(route).subscribe(x => { - result = x; - }).unsubscribe(); + const result = await firstValueFrom(schemaGuard.canActivate(route)); - expect(result!).toBeFalsy(); + expect(result).toBeFalsy(); router.verify(x => x.navigate(['/404']), Times.once()); }); diff --git a/frontend/app/shared/guards/schema-must-not-be-singleton.guard.spec.ts b/frontend/app/shared/guards/schema-must-not-be-singleton.guard.spec.ts index e3321615b..10ddb8811 100644 --- a/frontend/app/shared/guards/schema-must-not-be-singleton.guard.spec.ts +++ b/frontend/app/shared/guards/schema-must-not-be-singleton.guard.spec.ts @@ -7,7 +7,7 @@ import { Router, RouterStateSnapshot, UrlSegment } from '@angular/router'; import { SchemaDto, SchemasState } from '@app/shared/internal'; -import { of } from 'rxjs'; +import { firstValueFrom, of } from 'rxjs'; import { IMock, It, Mock, Times } from 'typemoq'; import { SchemaMustNotBeSingletonGuard } from './schema-must-not-be-singleton.guard'; @@ -33,53 +33,41 @@ describe('SchemaMustNotBeSingletonGuard', () => { schemaGuard = new SchemaMustNotBeSingletonGuard(schemasState.object, router.object); }); - it('should subscribe to schema and return true if default', () => { + it('should subscribe to schema and return true if default', async () => { const state: RouterStateSnapshot = { url: 'schemas/name/' }; schemasState.setup(x => x.selectedSchema) .returns(() => of({ id: '123', type: 'Default' })); - let result: boolean; + const result = await firstValueFrom(schemaGuard.canActivate(route, state)); - schemaGuard.canActivate(route, state).subscribe(x => { - result = x; - }).unsubscribe(); - - expect(result!).toBeTruthy(); + expect(result).toBeTruthy(); router.verify(x => x.navigate(It.isAny()), Times.never()); }); - it('should redirect to content if singleton', () => { + it('should redirect to content if singleton', async () => { const state: RouterStateSnapshot = { url: 'schemas/name/' }; schemasState.setup(x => x.selectedSchema) .returns(() => of({ id: '123', type: 'Singleton' })); - let result: boolean; - - schemaGuard.canActivate(route, state).subscribe(x => { - result = x; - }).unsubscribe(); + const result = await firstValueFrom(schemaGuard.canActivate(route, state)); - expect(result!).toBeFalsy(); + expect(result).toBeFalsy(); router.verify(x => x.navigate([state.url, '123']), Times.once()); }); - it('should redirect to content if singleton on new page', () => { + it('should redirect to content if singleton on new page', async () => { const state: RouterStateSnapshot = { url: 'schemas/name/new/' }; schemasState.setup(x => x.selectedSchema) .returns(() => of({ id: '123', type: 'Singleton' })); - let result: boolean; - - schemaGuard.canActivate(route, state).subscribe(x => { - result = x; - }).unsubscribe(); + const result = await firstValueFrom(schemaGuard.canActivate(route, state)); - expect(result!).toBeFalsy(); + expect(result).toBeFalsy(); router.verify(x => x.navigate(['schemas/name/', '123']), Times.once()); }); diff --git a/frontend/app/shared/guards/unset-app.guard.spec.ts b/frontend/app/shared/guards/unset-app.guard.spec.ts index f9264441c..5ce1a806f 100644 --- a/frontend/app/shared/guards/unset-app.guard.spec.ts +++ b/frontend/app/shared/guards/unset-app.guard.spec.ts @@ -6,7 +6,7 @@ */ import { AppsState } from '@app/shared/internal'; -import { of } from 'rxjs'; +import { firstValueFrom, of } from 'rxjs'; import { IMock, Mock, Times } from 'typemoq'; import { UnsetAppGuard } from './unset-app.guard'; @@ -19,15 +19,11 @@ describe('UnsetAppGuard', () => { appGuard = new UnsetAppGuard(appsState.object); }); - it('should unselect app', () => { + it('should unselect app', async () => { appsState.setup(x => x.select(null)) .returns(() => of(null)); - let result = false; - - appGuard.canActivate().subscribe(value => { - result = value; - }); + const result = await firstValueFrom(appGuard.canActivate()); expect(result).toBeTruthy(); diff --git a/frontend/app/shared/internal.ts b/frontend/app/shared/internal.ts index 76fe6810b..784a41bc6 100644 --- a/frontend/app/shared/internal.ts +++ b/frontend/app/shared/internal.ts @@ -58,6 +58,7 @@ export * from './state/languages.state'; export * from './state/plans.state'; export * from './state/queries'; export * from './state/query'; +export * from './state/resolvers'; export * from './state/roles.forms'; export * from './state/roles.state'; export * from './state/rule-events.state'; diff --git a/frontend/app/shared/module.ts b/frontend/app/shared/module.ts index af0388ce8..e75cbf1d1 100644 --- a/frontend/app/shared/module.ts +++ b/frontend/app/shared/module.ts @@ -12,7 +12,7 @@ import { RouterModule } from '@angular/router'; import { SqxFrameworkModule } from '@app/framework'; import { MentionModule } from 'angular-mentions'; import { NgxDocViewerModule } from 'ngx-doc-viewer'; -import { AppFormComponent, AppLanguagesService, AppMustExistGuard, AppsService, AppsState, AssetComponent, AssetDialogComponent, AssetFolderComponent, AssetFolderDialogComponent, AssetFolderDropdownComponent, AssetFolderDropdownItemComponent, AssetHistoryComponent, AssetPathComponent, AssetPreviewUrlPipe, AssetScriptsState, AssetsListComponent, AssetsSelectorComponent, AssetsService, AssetsState, AssetTextEditorComponent, AssetUploaderComponent, AssetUploaderState, AssetUrlPipe, AuthInterceptor, AuthService, AutoSaveService, BackupsService, BackupsState, ClientsService, ClientsState, CommentComponent, CommentsComponent, CommentsService, ContentListCellDirective, ContentListFieldComponent, ContentListHeaderComponent, ContentListWidthPipe, ContentMustExistGuard, ContentsColumnsPipe, ContentSelectorComponent, ContentSelectorItemComponent, ContentsService, ContentsState, ContentStatusComponent, ContentValueComponent, ContentValueEditorComponent, ContributorsService, ContributorsState, FileIconPipe, FilterComparisonComponent, FilterLogicalComponent, FilterNodeComponent, GeolocationEditorComponent, GraphQlService, HelpComponent, HelpMarkdownPipe, HelpService, HistoryComponent, HistoryListComponent, HistoryMessagePipe, HistoryService, ImageCropperComponent, ImageFocusPointComponent, LanguagesService, LanguagesState, LoadAppsGuard, LoadLanguagesGuard, LoadSchemasGuard, MarkdownEditorComponent, MustBeAuthenticatedGuard, MustBeNotAuthenticatedGuard, NewsService, NotifoComponent, PlansService, PlansState, PreviewableType, QueryComponent, QueryListComponent, QueryPathComponent, ReferenceDropdownComponent, ReferenceInputComponent, ReferencesCheckboxesComponent, ReferencesTagsComponent, RichEditorComponent, RolesService, RolesState, RuleEventsState, RuleMustExistGuard, RuleSimulatorState, RulesService, RulesState, SavedQueriesComponent, SchemaCategoryComponent, SchemaMustExistGuard, SchemaMustExistPublishedGuard, SchemaMustNotBeSingletonGuard, SchemasService, SchemasState, SchemaTagSource, SearchFormComponent, SearchService, SortingComponent, StockPhotoService, TableHeaderComponent, TranslationsService, UIService, UIState, UnsetAppGuard, UsagesService, UserDtoPicture, UserIdPicturePipe, UserNamePipe, UserNameRefPipe, UserPicturePipe, UserPictureRefPipe, UsersProviderService, UsersService, WatchingUsersComponent, WorkflowsService, WorkflowsState } from './declarations'; +import { AppFormComponent, AppLanguagesService, AppMustExistGuard, AppsService, AppsState, AssetComponent, AssetDialogComponent, AssetFolderComponent, AssetFolderDialogComponent, AssetFolderDropdownComponent, AssetFolderDropdownItemComponent, AssetHistoryComponent, AssetPathComponent, AssetPreviewUrlPipe, AssetScriptsState, AssetsListComponent, AssetsSelectorComponent, AssetsService, AssetsState, AssetTextEditorComponent, AssetUploaderComponent, AssetUploaderState, AssetUrlPipe, AuthInterceptor, AuthService, AutoSaveService, BackupsService, BackupsState, ClientsService, ClientsState, CommentComponent, CommentsComponent, CommentsService, ContentListCellDirective, ContentListFieldComponent, ContentListHeaderComponent, ContentListWidthPipe, ContentMustExistGuard, ContentsColumnsPipe, ContentSelectorComponent, ContentSelectorItemComponent, ContentsService, ContentsState, ContentStatusComponent, ContentValueComponent, ContentValueEditorComponent, ContributorsService, ContributorsState, FileIconPipe, FilterComparisonComponent, FilterLogicalComponent, FilterNodeComponent, GeolocationEditorComponent, GraphQlService, HelpComponent, HelpMarkdownPipe, HelpService, HistoryComponent, HistoryListComponent, HistoryMessagePipe, HistoryService, ImageCropperComponent, ImageFocusPointComponent, LanguagesService, LanguagesState, LoadAppsGuard, LoadLanguagesGuard, LoadSchemasGuard, MarkdownEditorComponent, MustBeAuthenticatedGuard, MustBeNotAuthenticatedGuard, NewsService, NotifoComponent, PlansService, PlansState, PreviewableType, QueryComponent, QueryListComponent, QueryPathComponent, ReferenceInputComponent, RichEditorComponent, RolesService, RolesState, RuleEventsState, RuleMustExistGuard, RuleSimulatorState, RulesService, RulesState, SavedQueriesComponent, SchemaCategoryComponent, SchemaMustExistGuard, SchemaMustExistPublishedGuard, SchemaMustNotBeSingletonGuard, SchemasService, SchemasState, SchemaTagSource, SearchFormComponent, SearchService, SortingComponent, StockPhotoService, TableHeaderComponent, TranslationsService, UIService, UIState, UnsetAppGuard, UsagesService, UserDtoPicture, UserIdPicturePipe, UserNamePipe, UserNameRefPipe, UserPicturePipe, UserPictureRefPipe, UsersProviderService, UsersService, WatchingUsersComponent, WorkflowsService, WorkflowsState } from './declarations'; @NgModule({ imports: [ @@ -68,10 +68,7 @@ import { AppFormComponent, AppLanguagesService, AppMustExistGuard, AppsService, QueryComponent, QueryListComponent, QueryPathComponent, - ReferenceDropdownComponent, ReferenceInputComponent, - ReferencesCheckboxesComponent, - ReferencesTagsComponent, RichEditorComponent, SavedQueriesComponent, SchemaCategoryComponent, @@ -123,10 +120,7 @@ import { AppFormComponent, AppLanguagesService, AppMustExistGuard, AppsService, NotifoComponent, PreviewableType, QueryListComponent, - ReferenceDropdownComponent, ReferenceInputComponent, - ReferencesCheckboxesComponent, - ReferencesTagsComponent, RichEditorComponent, RouterModule, SavedQueriesComponent, diff --git a/frontend/app/shared/state/apps.state.spec.ts b/frontend/app/shared/state/apps.state.spec.ts index 0945ec777..41132efd6 100644 --- a/frontend/app/shared/state/apps.state.spec.ts +++ b/frontend/app/shared/state/apps.state.spec.ts @@ -5,9 +5,8 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -import { AppDto, AppsService, AppsState, DialogService } from '@app/shared/internal'; -import { of, throwError } from 'rxjs'; -import { onErrorResumeNext } from 'rxjs/operators'; +import { AppsService, AppsState, DialogService } from '@app/shared/internal'; +import { firstValueFrom, of, throwError } from 'rxjs'; import { IMock, It, Mock, Times } from 'typemoq'; import { createApp, createAppSettings } from './../services/apps.service.spec'; @@ -43,14 +42,10 @@ describe('AppsState', () => { expect(appsState.snapshot.apps).toEqual([app1, app2]); }); - it('should select app', () => { - let selectedApp: AppDto; + it('should select app', async () => { + const appSelect = await firstValueFrom(appsState.select(app1.name)); - appsState.select(app1.name).subscribe(x => { - selectedApp = x!; - }); - - expect(selectedApp!).toBe(app1); + expect(appSelect).toBe(app1); expect(appsState.snapshot.selectedApp).toBe(app1); expect(appsState.snapshot.selectedSettings).not.toBeNull(); @@ -74,47 +69,35 @@ describe('AppsState', () => { expect().nothing(); }); - it('should return null on select if unselecting app', () => { - let appSelected: AppDto; + it('should return null on select if unselecting app', async () => { + const appSelected = await firstValueFrom(appsState.select(null)); - appsState.select(null).subscribe(x => { - appSelected = x!; - }); - - expect(appSelected!).toBeNull(); + expect(appSelected).toBeNull(); expect(appsState.snapshot.selectedApp).toBeNull(); expect(appsState.snapshot.selectedSettings).toBeNull(); appsService.verify(x => x.getSettings(It.isAnyString()), Times.never()); }); - it('should return new app if loaded', () => { - const newApp = createApp(1, '_new'); - - appsService.setup(x => x.getApp(app1.name)) - .returns(() => of(newApp)); - - let appSelected: AppDto; + it('should return null on select if app is not found', async () => { + appsService.setup(x => x.getApp('unknown')) + .returns(() => throwError(() => 'Service Error')); - appsState.loadApp(app1.name).subscribe(x => { - appSelected = x!; - }); + const appSelected = await firstValueFrom(appsState.select('unknown')); - expect(appSelected!).toEqual(newApp); + expect(appSelected).toBeNull(); expect(appsState.snapshot.selectedApp).toBeNull(); }); - it('should return null on select if app is not found', () => { - let appSelected: AppDto; + it('should return new app if loaded', async () => { + const newApp = createApp(1, '_new'); - appsService.setup(x => x.getApp('unknown')) - .returns(() => throwError(() => 'Service Error')); + appsService.setup(x => x.getApp(app1.name)) + .returns(() => of(newApp)); - appsState.select('unknown').pipe(onErrorResumeNext()).subscribe(x => { - appSelected = x!; - }); + const appSelected = await firstValueFrom(appsState.loadApp(app1.name)); - expect(appSelected!).toBeNull(); + expect(appSelected).toEqual(newApp); expect(appsState.snapshot.selectedApp).toBeNull(); }); diff --git a/frontend/app/shared/state/asset-uploader.state.spec.ts b/frontend/app/shared/state/asset-uploader.state.spec.ts index df8ae731e..bc63e30ce 100644 --- a/frontend/app/shared/state/asset-uploader.state.spec.ts +++ b/frontend/app/shared/state/asset-uploader.state.spec.ts @@ -5,8 +5,8 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -import { AssetDto, AssetsService, AssetsState, AssetUploaderState, DialogService, ofForever, Types } from '@app/shared/internal'; -import { NEVER, of, throwError } from 'rxjs'; +import { AssetsService, AssetsState, AssetUploaderState, DialogService, ofForever } from '@app/shared/internal'; +import { lastValueFrom, NEVER, of, throwError } from 'rxjs'; import { onErrorResumeNext } from 'rxjs/operators'; import { IMock, Mock } from 'typemoq'; import { createAsset } from './../services/assets.service.spec'; @@ -93,27 +93,19 @@ describe('AssetUploaderState', () => { expect(upload.progress).toBe(1); }); - it('should update status if uploading file completes', (cb) => { + it('should update status if uploading file completes', async () => { const file: File = { name: 'my-file' }; assetsService.setup(x => x.postAssetFile(app, file, undefined)) .returns(() => of(10, 20, asset)).verifiable(); - let uploadedAsset: AssetDto; - - assetUploader.uploadFile(file).subscribe(dto => { - if (Types.is(dto, AssetDto)) { - uploadedAsset = dto; - } - - cb(); - }); + const uploadedAsset = await lastValueFrom(assetUploader.uploadFile(file)); const upload = assetUploader.snapshot.uploads[0]; expect(upload.status).toBe('Completed'); expect(upload.progress).toBe(100); - expect(uploadedAsset!).toEqual(asset); + expect(uploadedAsset).toEqual(asset); }); it('should create initial state if uploading asset', () => { @@ -145,7 +137,7 @@ describe('AssetUploaderState', () => { }); it('should update status if uploading asset failed', () => { - const file: File = { name: 'my-file' }; + const file: File = { name: 'my-file' } as any; assetsService.setup(x => x.putAssetFile(app, asset, file, asset.version)) .returns(() => throwError(() => 'Service Error')).verifiable(); @@ -158,21 +150,15 @@ describe('AssetUploaderState', () => { expect(upload.progress).toBe(1); }); - it('should update status if uploading asset completes', () => { - const file: File = { name: 'my-file' }; + it('should update status if uploading asset completes', async () => { + const file: File = { name: 'my-file' } as any; const updated = createAsset(1, undefined, '_new'); assetsService.setup(x => x.putAssetFile(app, asset, file, asset.version)) .returns(() => of(10, 20, updated)).verifiable(); - let uploadedAsset: AssetDto; - - assetUploader.uploadAsset(asset, file).subscribe(dto => { - if (Types.is(dto, AssetDto)) { - uploadedAsset = dto; - } - }); + const uploadedAsset = await lastValueFrom(assetUploader.uploadAsset(asset, file)); const upload = assetUploader.snapshot.uploads[0]; diff --git a/frontend/app/shared/state/contents.forms.spec.ts b/frontend/app/shared/state/contents.forms.spec.ts index d4510d57a..db21bf66e 100644 --- a/frontend/app/shared/state/contents.forms.spec.ts +++ b/frontend/app/shared/state/contents.forms.spec.ts @@ -804,8 +804,8 @@ describe('ContentForm', () => { let value: any; - simpleForm.valueChanges.subscribe(v => { - value = v; + simpleForm.valueChanges.subscribe(result => { + value = result; }); expect(value).toEqual({ field1: { iv: 'Change' } }); diff --git a/frontend/app/shared/state/resolvers.spec.ts b/frontend/app/shared/state/resolvers.spec.ts new file mode 100644 index 000000000..3d29d7090 --- /dev/null +++ b/frontend/app/shared/state/resolvers.spec.ts @@ -0,0 +1,209 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { UIOptions } from '@app/framework'; +import { firstValueFrom, of, throwError } from 'rxjs'; +import { IMock, Mock, Times } from 'typemoq'; +import { ContentsDto, ContentsService } from '../services/contents.service'; +import { createContent } from '../services/contents.service.spec'; +import { ResolveContents } from './resolvers'; +import { TestValues } from './_test-helpers'; + +describe('ResolveContents', () => { + const { + app, + appsState, + } = TestValues; + + const uiOptions = new UIOptions({ + referencesDropdownItemCount: 100, + }); + + let contentsService: IMock; + let contentsResolver: ResolveContents; + + const contents = [ + createContent(1), + createContent(2), + createContent(3), + createContent(4), + ]; + + beforeEach(() => { + contentsService = Mock.ofType(); + contentsResolver = new ResolveContents(uiOptions, appsState.object, contentsService.object); + }); + + it('should not resolve contents immediately', () => { + const ids = ['id1', 'id2']; + + contentsService.setup(x => x.getAllContents(app, { ids })) + .returns(() => of(new ContentsDto([], 2, [contents[0], contents[1]]))); + + return expectAsync(firstValueFrom(contentsResolver.resolveMany(ids))).toBePending(); + }); + + it('should resolve content from one request after delay', async () => { + const ids = ['id1', 'id2']; + + contentsService.setup(x => x.getAllContents(app, { ids })) + .returns(() => of(new ContentsDto([], 2, [contents[0], contents[1]]))); + + const result = await firstValueFrom(contentsResolver.resolveMany(ids)); + + expect(result.items).toEqual([ + contents[0], + contents[1], + ]); + }); + + it('should resolve content if not found', async () => { + const ids = ['id1', 'id2']; + + contentsService.setup(x => x.getAllContents(app, { ids })) + .returns(() => of(new ContentsDto([], 2, [contents[0]]))); + + const result = await firstValueFrom(contentsResolver.resolveMany(ids)); + + expect(result.items).toEqual([ + contents[0], + ]); + }); + + it('should resolve errors', () => { + const ids = ['id1', 'id2']; + + contentsService.setup(x => x.getAllContents(app, { ids })) + .returns(() => throwError(() => new Error('error'))); + + return expectAsync(firstValueFrom(contentsResolver.resolveMany(ids))).toBeRejected(); + }); + + it('should batch results', async () => { + const ids1 = ['id1', 'id2']; + const ids2 = ['id2', 'id3']; + + const ids = ['id1', 'id2', 'id3']; + + contentsService.setup(x => x.getAllContents(app, { ids })) + .returns(() => of(new ContentsDto([], 2, [contents[0], contents[1], contents[2]]))); + + const result1Promise = firstValueFrom(contentsResolver.resolveMany(ids1)); + const result2Promise = firstValueFrom(contentsResolver.resolveMany(ids2)); + + const [result1, result2] = await Promise.all([result1Promise, result2Promise]); + + expect(result1.items).toEqual([ + contents[0], + contents[1], + ]); + + expect(result2.items).toEqual([ + contents[1], + contents[2], + ]); + + contentsService.verify(x => x.getAllContents(app, { ids }), Times.once()); + }); + + it('should cache results for parallel requests', async () => { + const ids = ['id1', 'id2']; + + contentsService.setup(x => x.getAllContents(app, { ids })) + .returns(() => of(new ContentsDto([], 2, [contents[0], contents[1]]))); + + const result1Promise = firstValueFrom(contentsResolver.resolveMany(ids)); + const result2Promise = firstValueFrom(contentsResolver.resolveMany(ids)); + + const [result1, result2] = await Promise.all([result1Promise, result2Promise]); + + expect(result1.items).toEqual([ + contents[0], + contents[1], + ]); + + expect(result2.items).toEqual([ + contents[0], + contents[1], + ]); + + contentsService.verify(x => x.getAllContents(app, { ids }), Times.once()); + }); + + it('should cache results', async () => { + const ids = ['id1', 'id2']; + + contentsService.setup(x => x.getAllContents(app, { ids })) + .returns(() => of(new ContentsDto([], 2, [contents[0], contents[1]]))); + + const result1 = await firstValueFrom(contentsResolver.resolveMany(ids)); + const result2 = await firstValueFrom(contentsResolver.resolveMany(ids)); + + expect(result1.items).toEqual([ + contents[0], + contents[1], + ]); + + expect(result2.items).toEqual([ + contents[0], + contents[1], + ]); + + contentsService.verify(x => x.getAllContents(app, { ids }), Times.once()); + }); + + it('should resolve all contents', async () => { + const schema = 'schema1'; + + contentsService.setup(x => x.getContents(app, schema, { take: 100 })) + .returns(() => of(new ContentsDto([], 2, [contents[0]]))); + + const result = await firstValueFrom(contentsResolver.resolveAll('schema1')); + + expect(result.items).toEqual([ + contents[0], + ]); + }); + + it('should cache all contents for parallel requests', async () => { + const schema = 'schema1'; + + contentsService.setup(x => x.getContents(app, schema, { take: 100 })) + .returns(() => of(new ContentsDto([], 2, [contents[0]]))); + + const result1Promise = await firstValueFrom(contentsResolver.resolveAll('schema1')); + const result2Promise = await firstValueFrom(contentsResolver.resolveAll('schema1')); + + const [result1, result2] = await Promise.all([result1Promise, result2Promise]); + + expect(result1.items).toEqual([ + contents[0], + ]); + + expect(result2.items).toEqual([ + contents[0], + ]); + }); + + it('should cache all contents', async () => { + const schema = 'schema1'; + + contentsService.setup(x => x.getContents(app, schema, { take: 100 })) + .returns(() => of(new ContentsDto([], 2, [contents[0]]))); + + const result1 = await firstValueFrom(contentsResolver.resolveAll('schema1')); + const result2 = await firstValueFrom(contentsResolver.resolveAll('schema1')); + + expect(result1.items).toEqual([ + contents[0], + ]); + + expect(result2.items).toEqual([ + contents[0], + ]); + }); +}); diff --git a/frontend/app/shared/state/resolvers.ts b/frontend/app/shared/state/resolvers.ts new file mode 100644 index 000000000..aa5a264af --- /dev/null +++ b/frontend/app/shared/state/resolvers.ts @@ -0,0 +1,206 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { Injectable } from '@angular/core'; +import { Observable, from, of, shareReplay } from 'rxjs'; +import { UIOptions } from '@app/framework'; +import { AssetDto, AssetsDto, AssetsService } from './../services/assets.service'; +import { AppsState } from './apps.state'; +import { ContentDto, ContentsDto, ContentsService } from './../services/contents.service'; + +abstract class ResolverBase }> { + private readonly items: { [id: string]: Deferred } = {}; + private pending: { [id: string]: boolean } | null = null; + + public resolveMany(ids: ReadonlyArray): Observable { + if (ids.length === 0) { + return of(this.createResult([])); + } + + const nonResolved: string[] = []; + + const promises: Promise[] = []; + + for (const id of ids) { + let deferred = this.items[id]; + + if (!deferred) { + deferred = new Deferred(); + this.items[id] = deferred; + + nonResolved.push(id); + } + + promises.push(deferred.promise); + } + + if (nonResolved.length > 0) { + if (this.pending === null) { + this.pending = {}; + + setTimeout(() => { + this.resolvePending(); + }, 100); + } + + for (const id of nonResolved) { + this.pending[id] = true; + } + } + + return from(this.buildPromise(promises)); + } + + private async buildPromise(promises: Promise[]) { + const promise = await Promise.all(promises); + + return this.createResult(promise.filter(x => !!x) as any); + } + + private resolvePending() { + if (!this.pending) { + return; + } + + const allIds = Object.keys(this.pending); + + if (allIds.length === 0) { + return; + } + + this.pending = null; + + for (const ids of chunkArray(allIds, 100)) { + this.resolveIds(ids); + } + } + + protected abstract createResult(items: T[]): TResult; + + protected abstract loadMany(ids: string[]): Observable; + + private resolveIds(ids: string[]) { + this.loadMany(ids) + .subscribe({ + next: results => { + for (const id of ids) { + const content = results.items.find(x => x.id === id); + + this.items[id]?.resolve(content); + } + }, + error: ex => { + for (const id of ids) { + this.items[id]?.reject(ex); + } + }, + }); + } +} + +@Injectable() +export class ResolveContents extends ResolverBase { + private readonly schemas: { [name: string]: Observable } = {}; + private readonly itemCount; + + constructor( + uiOptions: UIOptions, + private readonly appsState: AppsState, + private readonly contentsService: ContentsService, + ) { + super(); + + this.itemCount = uiOptions.get('referencesDropdownItemCount'); + } + + public resolveAll(schema: string) { + let result = this.schemas[schema]; + + if (!result) { + result = this.contentsService.getContents(this.appName, schema, { take: this.itemCount }).pipe(shareReplay(1)); + + this.schemas[schema] = result; + } + + return result; + } + + protected createResult(items: ContentDto[]) { + return new ContentsDto([], items.length, items); + } + + protected loadMany(ids: string[]) { + return this.contentsService.getAllContents(this.appName, { ids }); + } + + private get appName() { + return this.appsState.appName; + } +} + +@Injectable() +export class ResolveAssets extends ResolverBase { + constructor( + private readonly appsState: AppsState, + private readonly assetsService: AssetsService, + ) { + super(); + } + + protected createResult(items: AssetDto[]) { + return new AssetsDto(items.length, items); + } + + protected loadMany(ids: string[]) { + return this.assetsService.getAssets(this.appName, { ids }); + } + + private get appName() { + return this.appsState.appName; + } +} + +function chunkArray(array: T[], size: number): T[][] { + if (array.length > size) { + return [array.slice(0, size), ...chunkArray(array.slice(size), size)]; + } else { + return [array]; + } +} + +class Deferred { + private handleResolve: Function; + private handleReject: Function; + private isHandled = false; + + public readonly promise: Promise; + + constructor() { + this.promise = new Promise((resolve, reject) => { + this.handleResolve = resolve; + this.handleReject = reject; + }); + } + + public resolve(value: T | PromiseLike) { + if (this.isHandled) { + return; + } + + this.isHandled = true; + this.handleResolve(value); + } + + public reject(reason?: any) { + if (this.isHandled) { + return; + } + + this.isHandled = true; + this.handleReject(reason); + } +} diff --git a/frontend/app/shared/state/rules.state.spec.ts b/frontend/app/shared/state/rules.state.spec.ts index 6bef221e1..8ed99e776 100644 --- a/frontend/app/shared/state/rules.state.spec.ts +++ b/frontend/app/shared/state/rules.state.spec.ts @@ -6,7 +6,7 @@ */ import { DialogService, RulesDto, RulesService, versioned } from '@app/shared/internal'; -import { of, throwError } from 'rxjs'; +import { firstValueFrom, of, throwError } from 'rxjs'; import { onErrorResumeNext } from 'rxjs/operators'; import { IMock, It, Mock, Times } from 'typemoq'; import { RuleDto } from './../services/rules.service'; @@ -52,13 +52,13 @@ describe('RulesState', () => { expect(rulesState.snapshot.isLoading).toBeFalsy(); expect(rulesState.snapshot.rules).toEqual([rule1, rule2]); - let runningRule: RuleDto | undefined; + let ruleRunning: RuleDto | undefined; rulesState.runningRule.subscribe(result => { - runningRule = result; + ruleRunning = result; }); - expect(runningRule).toBe(rule1); + expect(ruleRunning).toBe(rule1); expect(rulesState.snapshot.runningRuleId).toBe(rule1.id); dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never()); @@ -93,25 +93,17 @@ describe('RulesState', () => { rulesState.load().subscribe(); }); - it('should return rule on select and not load if already loaded', () => { - let ruleSelected: RuleDto; + it('should return rule on select and not load if already loaded', async () => { + const ruleSelected = await firstValueFrom(rulesState.select(rule1.id)); - rulesState.select(rule1.id).subscribe(x => { - ruleSelected = x!; - }); - - expect(ruleSelected!).toEqual(rule1); + expect(ruleSelected).toEqual(rule1); expect(rulesState.snapshot.selectedRule).toEqual(rule1); }); - it('should return null on select if unselecting rule', () => { - let ruleSelected: RuleDto; - - rulesState.select(null).subscribe(x => { - ruleSelected = x!; - }); + it('should return null on select if unselecting rule', async () => { + const ruleSelected = await firstValueFrom(rulesState.select(null)); - expect(ruleSelected!).toBeNull(); + expect(ruleSelected).toBeNull(); expect(rulesState.snapshot.selectedRule).toBeNull(); }); diff --git a/frontend/app/shared/state/schemas.state.spec.ts b/frontend/app/shared/state/schemas.state.spec.ts index 48d5bfc36..6c3bfd696 100644 --- a/frontend/app/shared/state/schemas.state.spec.ts +++ b/frontend/app/shared/state/schemas.state.spec.ts @@ -5,8 +5,8 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -import { DialogService, FieldDto, SchemaDto, SchemasService, UpdateSchemaCategoryDto, versioned } from '@app/shared/internal'; -import { of, throwError } from 'rxjs'; +import { DialogService, SchemaDto, SchemasService, UpdateSchemaCategoryDto, versioned } from '@app/shared/internal'; +import { firstValueFrom, of, throwError } from 'rxjs'; import { onErrorResumeNext } from 'rxjs/operators'; import { IMock, It, Mock, Times } from 'typemoq'; import { createSchema } from './../services/schemas.service.spec'; @@ -151,29 +151,21 @@ describe('SchemasState', () => { expect().nothing(); }); - it('should return schema on select and reload always', () => { + it('should return schema on select and reload always', async () => { schemasService.setup(x => x.getSchema(app, schema1.name)) .returns(() => of(schema1)).verifiable(); - let schemaSelected: SchemaDto; + const schemaSelected = await firstValueFrom(schemasState.select(schema1.name)); - schemasState.select(schema1.name).subscribe(x => { - schemaSelected = x!; - }); - - expect(schemaSelected!).toBe(schema1); + expect(schemaSelected).toBe(schema1); expect(schemasState.snapshot.selectedSchema).toBe(schema1); expect(schemasState.snapshot.selectedSchema).toBe(schemasState.snapshot.schemas[0]); }); - it('should return null on select if unselecting schema', () => { - let schemaSelected: SchemaDto; - - schemasState.select(null).subscribe(x => { - schemaSelected = x!; - }); + it('should return null on select if unselecting schema', async () => { + const schemaSelected = await firstValueFrom(schemasState.select(null)); - expect(schemaSelected!).toBeNull(); + expect(schemaSelected).toBeNull(); expect(schemasState.snapshot.selectedSchema).toBeNull(); }); @@ -325,7 +317,7 @@ describe('SchemasState', () => { expect(schemasState.snapshot.selectedSchema).toBeNull(); }); - it('should update schema and selected schema if field added', () => { + it('should update schema and selected schema if field added', async () => { const request = { ...schema1.fields[0] }; const updated = createSchema(1, '_new'); @@ -333,18 +325,14 @@ describe('SchemasState', () => { schemasService.setup(x => x.postField(app, schema1, It.isAny(), version)) .returns(() => of(updated)).verifiable(); - let newField: FieldDto; - - schemasState.addField(schema1, request).subscribe(result => { - newField = result; - }); + const schemaField = await firstValueFrom(schemasState.addField(schema1, request)); + expect(schemaField).toBeDefined(); expect(schemasState.snapshot.schemas).toEqual([updated, schema2]); expect(schemasState.snapshot.selectedSchema).toEqual(updated); - expect(newField!).toBeDefined(); }); - it('should update schema and selected schema if nested field added', () => { + it('should update schema and selected schema if nested field added', async () => { const request = { ...schema1.fields[0].nested[0] }; const updated = createSchema(1, '_new'); @@ -352,15 +340,11 @@ describe('SchemasState', () => { schemasService.setup(x => x.postField(app, schema1.fields[0], It.isAny(), version)) .returns(() => of(updated)).verifiable(); - let newField: FieldDto; - - schemasState.addField(schema1, request, schema1.fields[0]).subscribe(result => { - newField = result; - }); + const schemaField = await firstValueFrom(schemasState.addField(schema1, request, schema1.fields[0])); + expect(schemaField).toBeDefined(); expect(schemasState.snapshot.schemas).toEqual([updated, schema2]); expect(schemasState.snapshot.selectedSchema).toEqual(updated); - expect(newField!).toBeDefined(); }); it('should update schema and selected schema if field removed', () => { diff --git a/frontend/app/shared/state/table-fields.spec.ts b/frontend/app/shared/state/table-fields.spec.ts index 68fd3a1d2..d17360a6a 100644 --- a/frontend/app/shared/state/table-fields.spec.ts +++ b/frontend/app/shared/state/table-fields.spec.ts @@ -38,7 +38,7 @@ describe('TableFields', () => { ]; INVALID_CONFIGS.forEach(test => { - it(`should provide default fields if config is ${test.case}`, () => { + it(`should provide default fields if config is ${test.case}`, async () => { let fields: ReadonlyArray; let fieldNames: ReadonlyArray;