Browse Source

Feature/resolve batching (#805)

* Batch references.

* Tests for resolvers.

* Easier async code in tests

* Tests fixes.
pull/806/head
Sebastian Stehle 4 years ago
committed by GitHub
parent
commit
72d6259988
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 42
      frontend/app/features/administration/guards/user-must-exist.guard.spec.ts
  2. 42
      frontend/app/features/administration/state/users.state.spec.ts
  3. 3
      frontend/app/features/content/declarations.ts
  4. 6
      frontend/app/features/content/module.ts
  5. 4
      frontend/app/features/content/pages/content/content-page.component.ts
  6. 26
      frontend/app/features/content/shared/forms/assets-editor.component.ts
  7. 4
      frontend/app/features/content/shared/references/content-creator.component.ts
  8. 0
      frontend/app/features/content/shared/references/reference-dropdown.component.html
  9. 0
      frontend/app/features/content/shared/references/reference-dropdown.component.scss
  10. 20
      frontend/app/features/content/shared/references/reference-dropdown.component.ts
  11. 0
      frontend/app/features/content/shared/references/references-checkboxes.component.html
  12. 0
      frontend/app/features/content/shared/references/references-checkboxes.component.scss
  13. 0
      frontend/app/features/content/shared/references/references-checkboxes.component.ts
  14. 11
      frontend/app/features/content/shared/references/references-editor.component.ts
  15. 0
      frontend/app/features/content/shared/references/references-tag-converter.ts
  16. 0
      frontend/app/features/content/shared/references/references-tags.component.html
  17. 0
      frontend/app/features/content/shared/references/references-tags.component.scss
  18. 20
      frontend/app/features/content/shared/references/references-tags.component.ts
  19. 3
      frontend/app/shared/declarations.ts
  20. 26
      frontend/app/shared/guards/app-must-exist.guard.spec.ts
  21. 38
      frontend/app/shared/guards/content-must-exist.guard.spec.ts
  22. 10
      frontend/app/shared/guards/load-apps.guard.spec.ts
  23. 10
      frontend/app/shared/guards/load-languages.guard.spec.ts
  24. 10
      frontend/app/shared/guards/load-schemas.guard.spec.ts
  25. 28
      frontend/app/shared/guards/must-be-authenticated.guard.spec.ts
  26. 30
      frontend/app/shared/guards/must-be-not-authenticated.guard.spec.ts
  27. 42
      frontend/app/shared/guards/rule-must-exist.guard.spec.ts
  28. 42
      frontend/app/shared/guards/schema-must-exist-published.guard.spec.ts
  29. 22
      frontend/app/shared/guards/schema-must-exist.guard.spec.ts
  30. 32
      frontend/app/shared/guards/schema-must-not-be-singleton.guard.spec.ts
  31. 10
      frontend/app/shared/guards/unset-app.guard.spec.ts
  32. 1
      frontend/app/shared/internal.ts
  33. 8
      frontend/app/shared/module.ts
  34. 55
      frontend/app/shared/state/apps.state.spec.ts
  35. 32
      frontend/app/shared/state/asset-uploader.state.spec.ts
  36. 4
      frontend/app/shared/state/contents.forms.spec.ts
  37. 209
      frontend/app/shared/state/resolvers.spec.ts
  38. 206
      frontend/app/shared/state/resolvers.ts
  39. 28
      frontend/app/shared/state/rules.state.spec.ts
  40. 44
      frontend/app/shared/state/schemas.state.spec.ts
  41. 2
      frontend/app/shared/state/table-fields.spec.ts

42
frontend/app/features/administration/guards/user-must-exist.guard.spec.ts

@ -7,7 +7,7 @@
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { UserDto, UsersState } from '@app/features/administration/internal'; import { UserDto, UsersState } from '@app/features/administration/internal';
import { of } from 'rxjs'; import { firstValueFrom, of } from 'rxjs';
import { IMock, Mock, Times } from 'typemoq'; import { IMock, Mock, Times } from 'typemoq';
import { UserMustExistGuard } from './user-must-exist.guard'; import { UserMustExistGuard } from './user-must-exist.guard';
@ -22,86 +22,70 @@ describe('UserMustExistGuard', () => {
userGuard = new UserMustExistGuard(usersState.object, router.object); 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')) usersState.setup(x => x.select('123'))
.returns(() => of(<UserDto>{})); .returns(() => of(<UserDto>{}));
let result: boolean;
const route: any = { const route: any = {
params: { params: {
userId: '123', userId: '123',
}, },
}; };
userGuard.canActivate(route).subscribe(x => { const result = await firstValueFrom(userGuard.canActivate(route));
result = x;
}).unsubscribe();
expect(result!).toBeTruthy(); expect(result).toBeTruthy();
usersState.verify(x => x.select('123'), Times.once()); 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')) usersState.setup(x => x.select('123'))
.returns(() => of(null)); .returns(() => of(null));
let result: boolean;
const route: any = { const route: any = {
params: { params: {
userId: '123', userId: '123',
}, },
}; };
userGuard.canActivate(route).subscribe(x => { const result = await firstValueFrom(userGuard.canActivate(route));
result = x;
}).unsubscribe();
expect(result!).toBeFalsy(); expect(result).toBeFalsy();
router.verify(x => x.navigate(['/404']), Times.once()); 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)) usersState.setup(x => x.select(null))
.returns(() => of(null)); .returns(() => of(null));
let result: boolean;
const route: any = { const route: any = {
params: { params: {
userId: undefined, userId: undefined,
}, },
}; };
userGuard.canActivate(route).subscribe(x => { const result = await firstValueFrom(userGuard.canActivate(route));
result = x;
}).unsubscribe();
expect(result!).toBeTruthy(); expect(result).toBeTruthy();
usersState.verify(x => x.select(null), Times.once()); usersState.verify(x => x.select(null), Times.once());
}); });
it('should unset user if user id is <new>', () => { it('should unset user if user id is <new>', async () => {
usersState.setup(x => x.select(null)) usersState.setup(x => x.select(null))
.returns(() => of(null)); .returns(() => of(null));
let result: boolean;
const route: any = { const route: any = {
params: { params: {
userId: 'new', userId: 'new',
}, },
}; };
userGuard.canActivate(route).subscribe(x => { const result = await firstValueFrom(userGuard.canActivate(route));
result = x;
}).unsubscribe();
expect(result!).toBeTruthy(); expect(result).toBeTruthy();
usersState.verify(x => x.select(null), Times.once()); usersState.verify(x => x.select(null), Times.once());
}); });

42
frontend/app/features/administration/state/users.state.spec.ts

@ -5,9 +5,9 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * 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 { DialogService } from '@app/shared';
import { of, throwError } from 'rxjs'; import { firstValueFrom, of, throwError } from 'rxjs';
import { onErrorResumeNext } from 'rxjs/operators'; import { onErrorResumeNext } from 'rxjs/operators';
import { IMock, It, Mock, Times } from 'typemoq'; import { IMock, It, Mock, Times } from 'typemoq';
import { createUser } from './../services/users.service.spec'; import { createUser } from './../services/users.service.spec';
@ -98,53 +98,37 @@ describe('UsersState', () => {
usersState.load().subscribe(); usersState.load().subscribe();
}); });
it('should return user on select and not load if already loaded', () => { it('should return user on select and not load if already loaded', async () => {
let selectedUser: UserDto; const userSelected = await firstValueFrom(usersState.select(user1.id));
usersState.select(user1.id).subscribe(x => { expect(userSelected).toEqual(user1);
selectedUser = x!;
});
expect(selectedUser!).toEqual(user1);
expect(usersState.snapshot.selectedUser).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')) usersService.setup(x => x.getUser('id3'))
.returns(() => of(newUser)); .returns(() => of(newUser));
let userSelected: UserDto; const userSelected = await firstValueFrom(usersState.select('id3'));
usersState.select('id3').subscribe(x => {
userSelected = x!;
});
expect(userSelected!).toEqual(newUser); expect(userSelected!).toEqual(newUser);
expect(usersState.snapshot.selectedUser).toEqual(newUser); expect(usersState.snapshot.selectedUser).toEqual(newUser);
}); });
it('should return null on select if unselecting user', () => { it('should return null on select if unselecting user', async () => {
let userSelected: UserDto; const userSelected = await firstValueFrom(usersState.select(null));
usersState.select(null).subscribe(x => { expect(userSelected).toBeNull();
userSelected = x!;
});
expect(userSelected!).toBeNull();
expect(usersState.snapshot.selectedUser).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')) usersService.setup(x => x.getUser('unknown'))
.returns(() => throwError(() => 'Service Error')).verifiable(); .returns(() => throwError(() => 'Service Error')).verifiable();
let userSelected: UserDto; const userSelected = await firstValueFrom(usersState.select('unknown'));
usersState.select('unknown').pipe(onErrorResumeNext()).subscribe(x => {
userSelected = x!;
}).unsubscribe();
expect(userSelected!).toBeNull(); expect(userSelected).toBeNull();
expect(usersState.snapshot.selectedUser).toBeNull(); expect(usersState.snapshot.selectedUser).toBeNull();
}); });

3
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/list/content.component';
export * from './shared/preview-button.component'; export * from './shared/preview-button.component';
export * from './shared/references/content-creator.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/reference-item.component';
export * from './shared/references/references-checkboxes.component';
export * from './shared/references/references-editor.component'; export * from './shared/references/references-editor.component';
export * from './shared/references/references-tags.component';

6
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 { CanDeactivateGuard, ContentMustExistGuard, LoadLanguagesGuard, LoadSchemasGuard, SchemaMustExistPublishedGuard, SchemaMustNotBeSingletonGuard, SqxFrameworkModule, SqxSharedModule } from '@app/shared';
import { ScrollingModule } from '@angular/cdk/scrolling'; import { ScrollingModule } from '@angular/cdk/scrolling';
import { ScrollingModule as ScrollingModuleExperimental } from '@angular/cdk-experimental/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 = [ const routes: Routes = [
{ {
@ -121,8 +121,12 @@ const routes: Routes = [
FieldLanguagesComponent, FieldLanguagesComponent,
IFrameEditorComponent, IFrameEditorComponent,
PreviewButtonComponent, PreviewButtonComponent,
ReferenceDropdownComponent,
ReferenceItemComponent, ReferenceItemComponent,
ReferencesCheckboxesComponent,
ReferencesEditorComponent, ReferencesEditorComponent,
ReferencesEditorComponent,
ReferencesTagsComponent,
SchemasPageComponent, SchemasPageComponent,
SidebarPageComponent, SidebarPageComponent,
StockPhotoEditorComponent, StockPhotoEditorComponent,

4
frontend/app/features/content/pages/content/content-page.component.ts

@ -7,7 +7,7 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router'; 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 { Observable, of } from 'rxjs';
import { filter, map, tap } from 'rxjs/operators'; import { filter, map, tap } from 'rxjs/operators';
@ -19,6 +19,8 @@ import { filter, map, tap } from 'rxjs/operators';
fadeAnimation, fadeAnimation,
], ],
providers: [ providers: [
ResolveAssets,
ResolveContents,
ToolbarService, ToolbarService,
], ],
}) })

26
frontend/app/features/content/shared/forms/assets-editor.component.ts

@ -8,7 +8,7 @@
import { CdkDragDrop } from '@angular/cdk/drag-drop'; import { CdkDragDrop } from '@angular/cdk/drag-drop';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, forwardRef, Input, OnInit } from '@angular/core'; import { ChangeDetectionStrategy, ChangeDetectorRef, Component, forwardRef, Input, OnInit } from '@angular/core';
import { NG_VALUE_ACCESSOR } from '@angular/forms'; 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 = { export const SQX_ASSETS_EDITOR_CONTROL_VALUE_ACCESSOR: any = {
provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => AssetsEditorComponent), multi: true, provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => AssetsEditorComponent), multi: true,
@ -57,8 +57,7 @@ export class AssetsEditorComponent extends StatefulControlComponent<State, Reado
public assetsDialog = new DialogModel(); public assetsDialog = new DialogModel();
constructor(changeDetector: ChangeDetectorRef, constructor(changeDetector: ChangeDetectorRef,
private readonly appsState: AppsState, private readonly assetsResolver: ResolveAssets,
private readonly assetsService: AssetsService,
private readonly localStore: LocalStoreService, private readonly localStore: LocalStoreService,
private readonly messageBus: MessageBus, private readonly messageBus: MessageBus,
) { ) {
@ -74,15 +73,18 @@ export class AssetsEditorComponent extends StatefulControlComponent<State, Reado
if (!Types.equals(obj, this.snapshot.assets.map(x => x.id))) { if (!Types.equals(obj, this.snapshot.assets.map(x => x.id))) {
const assetIds: string[] = obj; const assetIds: string[] = obj;
this.assetsService.getAssets(this.appsState.appName, { ids: obj }) this.assetsResolver.resolveMany(obj)
.subscribe(dtos => { .subscribe({
this.setAssets(assetIds.map(id => dtos.items.find(x => x.id === id)!).filter(a => !!a)); next: ({ items }) => {
this.setAssets(items);
if (this.snapshot.assets.length !== assetIds.length) {
this.updateValue(); if (this.snapshot.assets.length !== assetIds.length) {
} this.updateValue();
}, () => { }
this.setAssets([]); },
error: () => {
this.setAssets([]);
},
}); });
} }
} else { } else {

4
frontend/app/features/content/shared/references/content-creator.component.ts

@ -6,13 +6,15 @@
*/ */
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; 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({ @Component({
selector: 'sqx-content-creator[formContext][language][languages]', selector: 'sqx-content-creator[formContext][language][languages]',
styleUrls: ['./content-creator.component.scss'], styleUrls: ['./content-creator.component.scss'],
templateUrl: './content-creator.component.html', templateUrl: './content-creator.component.html',
providers: [ providers: [
ResolveAssets,
ResolveContents,
ComponentContentsState, ComponentContentsState,
], ],
}) })

0
frontend/app/shared/components/references/reference-dropdown.component.html → frontend/app/features/content/shared/references/reference-dropdown.component.html

0
frontend/app/shared/components/references/reference-dropdown.component.scss → frontend/app/features/content/shared/references/reference-dropdown.component.scss

20
frontend/app/shared/components/references/reference-dropdown.component.ts → 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 { ChangeDetectionStrategy, ChangeDetectorRef, Component, forwardRef, Input, OnChanges, SimpleChanges } from '@angular/core';
import { FormControl, NG_VALUE_ACCESSOR } from '@angular/forms'; import { FormControl, NG_VALUE_ACCESSOR } from '@angular/forms';
import { ContentsDto } from '@app/shared'; 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'; import { Observable } from 'rxjs';
export const SQX_REFERENCE_DROPDOWN_CONTROL_VALUE_ACCESSOR: any = { export const SQX_REFERENCE_DROPDOWN_CONTROL_VALUE_ACCESSOR: any = {
@ -37,7 +37,6 @@ const NO_EMIT = { emitEvent: false };
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class ReferenceDropdownComponent extends StatefulControlComponent<State, ReadonlyArray<string> | string> implements OnChanges { export class ReferenceDropdownComponent extends StatefulControlComponent<State, ReadonlyArray<string> | string> implements OnChanges {
private readonly itemCount: number;
private readonly contents: ContentDto[] = []; private readonly contents: ContentDto[] = [];
private isOpenedBefore = false; private isOpenedBefore = false;
private isLoadingFailed = false; private isLoadingFailed = false;
@ -65,17 +64,14 @@ export class ReferenceDropdownComponent extends StatefulControlComponent<State,
public control = new FormControl(''); public control = new FormControl('');
constructor(changeDetector: ChangeDetectorRef, uiOptions: UIOptions, constructor(changeDetector: ChangeDetectorRef,
private readonly appsState: AppsState, private readonly contentsResolver: ResolveContents,
private readonly contentsService: ContentsService,
private readonly localizer: LocalizerService, private readonly localizer: LocalizerService,
) { ) {
super(changeDetector, { super(changeDetector, {
contentNames: [], contentNames: [],
}); });
this.itemCount = uiOptions.get('referencesDropdownItemCount');
this.own( this.own(
value$(this.control) value$(this.control)
.subscribe((id: string) => { .subscribe((id: string) => {
@ -133,14 +129,14 @@ export class ReferenceDropdownComponent extends StatefulControlComponent<State,
} }
this.isOpenedBefore = true; this.isOpenedBefore = true;
this.loadMore(this.contentsService.getContents(this.appsState.appName, this.schemaId, { take: this.itemCount })); this.loadMore(this.contentsResolver.resolveAll(this.schemaId));
} }
private selectContent(id: string | undefined) { private selectContent(id: string | undefined) {
const isNewId = !this.contents.find(x => x.id === id); const isNewId = !this.contents.find(x => x.id === id);
if (id && isNewId) { 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); this.control.setValue(id, NO_EMIT);
@ -149,12 +145,12 @@ export class ReferenceDropdownComponent extends StatefulControlComponent<State,
private loadMore(observable: Observable<ContentsDto>) { private loadMore(observable: Observable<ContentsDto>) {
observable observable
.subscribe({ .subscribe({
next: ({ items: newContents }) => { next: ({ items }) => {
if (newContents.length === 0) { if (items.length === 0) {
return; return;
} }
for (const content of newContents) { for (const content of items) {
const index = this.contents.findIndex(x => x.id === content.id); const index = this.contents.findIndex(x => x.id === content.id);
if (index >= 0) { if (index >= 0) {

0
frontend/app/shared/components/references/references-checkboxes.component.html → frontend/app/features/content/shared/references/references-checkboxes.component.html

0
frontend/app/shared/components/references/references-checkboxes.component.scss → frontend/app/features/content/shared/references/references-checkboxes.component.scss

0
frontend/app/shared/components/references/references-checkboxes.component.ts → frontend/app/features/content/shared/references/references-checkboxes.component.ts

11
frontend/app/features/content/shared/references/references-editor.component.ts

@ -8,7 +8,7 @@
import { CdkDragDrop } from '@angular/cdk/drag-drop'; import { CdkDragDrop } from '@angular/cdk/drag-drop';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, forwardRef, Input } from '@angular/core'; import { ChangeDetectionStrategy, ChangeDetectorRef, Component, forwardRef, Input } from '@angular/core';
import { NG_VALUE_ACCESSOR } from '@angular/forms'; 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 = { export const SQX_REFERENCES_EDITOR_CONTROL_VALUE_ACCESSOR: any = {
provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => ReferencesEditorComponent), multi: true, provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => ReferencesEditorComponent), multi: true,
@ -58,8 +58,7 @@ export class ReferencesEditorComponent extends StatefulControlComponent<State, R
public contentSelectorDialog = new DialogModel(); public contentSelectorDialog = new DialogModel();
constructor(changeDetector: ChangeDetectorRef, constructor(changeDetector: ChangeDetectorRef,
private readonly appsState: AppsState, private readonly contentsResolver: ResolveContents,
private readonly contentsService: ContentsService,
) { ) {
super(changeDetector, { contentItems: [] }); super(changeDetector, { contentItems: [] });
} }
@ -69,10 +68,10 @@ export class ReferencesEditorComponent extends StatefulControlComponent<State, R
if (!Types.equals(obj, this.snapshot.contentItems.map(x => x.id))) { if (!Types.equals(obj, this.snapshot.contentItems.map(x => x.id))) {
const contentIds: string[] = obj; const contentIds: string[] = obj;
this.contentsService.getAllContents(this.appsState.appName, { ids: contentIds }) this.contentsResolver.resolveMany(contentIds)
.subscribe({ .subscribe({
next: dtos => { next: ({ items }) => {
this.setContentItems(contentIds.map(id => dtos.items.find(c => c.id === id)!).filter(r => !!r)); this.setContentItems(contentIds.map(id => items.find(c => c.id === id)!).filter(r => !!r));
if (this.snapshot.contentItems.length !== contentIds.length) { if (this.snapshot.contentItems.length !== contentIds.length) {
this.updateValue(); this.updateValue();

0
frontend/app/shared/components/references/references-tag-converter.ts → frontend/app/features/content/shared/references/references-tag-converter.ts

0
frontend/app/shared/components/references/references-tags.component.html → frontend/app/features/content/shared/references/references-tags.component.html

0
frontend/app/shared/components/references/references-tags.component.scss → frontend/app/features/content/shared/references/references-tags.component.scss

20
frontend/app/shared/components/references/references-tags.component.ts → 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 { ChangeDetectionStrategy, ChangeDetectorRef, Component, forwardRef, Input, OnChanges, SimpleChanges } from '@angular/core';
import { FormControl, NG_VALUE_ACCESSOR } from '@angular/forms'; import { FormControl, NG_VALUE_ACCESSOR } from '@angular/forms';
import { Types } from '@app/framework'; 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 { Observable } from 'rxjs';
import { ReferencesTagsConverter } from './references-tag-converter'; import { ReferencesTagsConverter } from './references-tag-converter';
@ -33,7 +33,6 @@ const NO_EMIT = { emitEvent: false };
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class ReferencesTagsComponent extends StatefulControlComponent<State, ReadonlyArray<string>> implements OnChanges { export class ReferencesTagsComponent extends StatefulControlComponent<State, ReadonlyArray<string>> implements OnChanges {
private readonly itemCount: number;
private readonly contents: ContentDto[] = []; private readonly contents: ContentDto[] = [];
private isOpenedBefore = false; private isOpenedBefore = false;
private isLoadingFailed = false; private isLoadingFailed = false;
@ -58,17 +57,14 @@ export class ReferencesTagsComponent extends StatefulControlComponent<State, Rea
public control = new FormControl([]); public control = new FormControl([]);
constructor(changeDetector: ChangeDetectorRef, uiOptions: UIOptions, constructor(changeDetector: ChangeDetectorRef,
private readonly appsState: AppsState, private readonly contentsResolver: ResolveContents,
private readonly contentsService: ContentsService,
private readonly localizer: LocalizerService, private readonly localizer: LocalizerService,
) { ) {
super(changeDetector, { super(changeDetector, {
converter: new ReferencesTagsConverter(null!, [], localizer), converter: new ReferencesTagsConverter(null!, [], localizer),
}); });
this.itemCount = uiOptions.get('referencesDropdownItemCount');
this.own( this.own(
this.control.valueChanges this.control.valueChanges
.subscribe((value: string[]) => { .subscribe((value: string[]) => {
@ -108,7 +104,7 @@ export class ReferencesTagsComponent extends StatefulControlComponent<State, Rea
} }
this.isOpenedBefore = true; this.isOpenedBefore = true;
this.loadMore(this.contentsService.getContents(this.appsState.appName, this.schemaId, { take: this.itemCount })); this.loadMore(this.contentsResolver.resolveAll(this.schemaId));
} }
public writeValue(obj: ReadonlyArray<string>) { public writeValue(obj: ReadonlyArray<string>) {
@ -123,7 +119,7 @@ export class ReferencesTagsComponent extends StatefulControlComponent<State, Rea
const newIds = ids?.filter(x => !this.contents?.find(y => y.id === x)); const newIds = ids?.filter(x => !this.contents?.find(y => y.id === x));
if (newIds && newIds.length > 0) { 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); this.control.setValue(ids, NO_EMIT);
@ -132,12 +128,12 @@ export class ReferencesTagsComponent extends StatefulControlComponent<State, Rea
private loadMore(observable: Observable<ContentsDto>) { private loadMore(observable: Observable<ContentsDto>) {
observable observable
.subscribe({ .subscribe({
next: ({ items: newContents }) => { next: ({ items }) => {
if (newContents.length === 0) { if (items.length === 0) {
return; return;
} }
for (const content of newContents) { for (const content of items) {
const index = this.contents.findIndex(x => x.id === content.id); const index = this.contents.findIndex(x => x.id === content.id);
if (index >= 0) { if (index >= 0) {

3
frontend/app/shared/declarations.ts

@ -41,10 +41,7 @@ export * from './components/notifo.component';
export * from './components/pipes'; export * from './components/pipes';
export * from './components/references/content-selector-item.component'; export * from './components/references/content-selector-item.component';
export * from './components/references/content-selector.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/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/schema-category.component';
export * from './components/search/queries/filter-comparison.component'; export * from './components/search/queries/filter-comparison.component';
export * from './components/search/queries/filter-logical.component'; export * from './components/search/queries/filter-logical.component';

26
frontend/app/shared/guards/app-must-exist.guard.spec.ts

@ -7,7 +7,7 @@
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { AppsState } from '@app/shared'; import { AppsState } from '@app/shared';
import { of } from 'rxjs'; import { firstValueFrom, of } from 'rxjs';
import { IMock, Mock, Times } from 'typemoq'; import { IMock, Mock, Times } from 'typemoq';
import { AppMustExistGuard } from './app-must-exist.guard'; import { AppMustExistGuard } from './app-must-exist.guard';
@ -30,33 +30,23 @@ describe('AppMustExistGuard', () => {
appGuard = new AppMustExistGuard(appsState.object, router.object); 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')) appsState.setup(x => x.select('my-app'))
.returns(() => of(null)); .returns(() => of(null));
let result: boolean; const result = await firstValueFrom(appGuard.canActivate(route));
appGuard.canActivate(route).subscribe(x => { expect(result).toBeFalsy();
result = x;
});
expect(result!).toBeFalsy(); router.verify(x => x.navigate(['/404']), Times.once());
appsState.verify(x => x.select('my-app'), 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')) appsState.setup(x => x.select('my-app'))
.returns(() => of(<any>{})); .returns(() => of(<any>{}));
let result: boolean; const result = await firstValueFrom(appGuard.canActivate(route));
appGuard.canActivate(route).subscribe(x => {
result = x;
});
expect(result!).toBeTruthy();
// router.verify(x => x.navigate(['/404']), Times.once()); expect(result).toBeTruthy();
}); });
}); });

38
frontend/app/shared/guards/content-must-exist.guard.spec.ts

@ -7,7 +7,7 @@
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { ContentDto, ContentsState } from '@app/shared/internal'; import { ContentDto, ContentsState } from '@app/shared/internal';
import { of } from 'rxjs'; import { firstValueFrom, of } from 'rxjs';
import { IMock, It, Mock, Times } from 'typemoq'; import { IMock, It, Mock, Times } from 'typemoq';
import { ContentMustExistGuard } from './content-must-exist.guard'; import { ContentMustExistGuard } from './content-must-exist.guard';
@ -22,84 +22,68 @@ describe('ContentMustExistGuard', () => {
contentGuard = new ContentMustExistGuard(contentsState.object, router.object); 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')) contentsState.setup(x => x.select('123'))
.returns(() => of(<ContentDto>{})); .returns(() => of(<ContentDto>{}));
let result: boolean;
const route: any = { const route: any = {
params: { params: {
contentId: '123', contentId: '123',
}, },
}; };
contentGuard.canActivate(route).subscribe(x => { const result = await firstValueFrom(contentGuard.canActivate(route));
result = x;
}).unsubscribe();
expect(result!).toBeTruthy(); expect(result).toBeTruthy();
router.verify(x => x.navigate(It.isAny()), Times.never()); 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')) contentsState.setup(x => x.select('123'))
.returns(() => of(null)); .returns(() => of(null));
let result: boolean;
const route: any = { const route: any = {
params: { params: {
contentId: '123', contentId: '123',
}, },
}; };
contentGuard.canActivate(route).subscribe(x => { const result = await firstValueFrom(contentGuard.canActivate(route));
result = x;
}).unsubscribe();
expect(result!).toBeFalsy(); expect(result).toBeFalsy();
router.verify(x => x.navigate(['/404']), Times.once()); 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)) contentsState.setup(x => x.select(null))
.returns(() => of(null)); .returns(() => of(null));
let result: boolean;
const route: any = { const route: any = {
params: { params: {
contentId: undefined, contentId: undefined,
}, },
}; };
contentGuard.canActivate(route).subscribe(x => { const result = await firstValueFrom(contentGuard.canActivate(route));
result = x;
}).unsubscribe();
expect(result!).toBeTruthy(); expect(result!).toBeTruthy();
contentsState.verify(x => x.select(null), Times.once()); contentsState.verify(x => x.select(null), Times.once());
}); });
it('should unset content if content id is <new>', () => { it('should unset content if content id is <new>', async () => {
contentsState.setup(x => x.select(null)) contentsState.setup(x => x.select(null))
.returns(() => of(null)); .returns(() => of(null));
let result: boolean;
const route: any = { const route: any = {
params: { params: {
contentId: 'new', contentId: 'new',
}, },
}; };
contentGuard.canActivate(route).subscribe(x => { const result = await firstValueFrom(contentGuard.canActivate(route));
result = x;
}).unsubscribe();
expect(result!).toBeTruthy(); expect(result!).toBeTruthy();

10
frontend/app/shared/guards/load-apps.guard.spec.ts

@ -6,7 +6,7 @@
*/ */
import { AppsState } from '@app/shared'; import { AppsState } from '@app/shared';
import { of } from 'rxjs'; import { firstValueFrom, of } from 'rxjs';
import { IMock, Mock, Times } from 'typemoq'; import { IMock, Mock, Times } from 'typemoq';
import { LoadAppsGuard } from './load-apps.guard'; import { LoadAppsGuard } from './load-apps.guard';
@ -19,15 +19,11 @@ describe('LoadAppsGuard', () => {
appGuard = new LoadAppsGuard(appsState.object); appGuard = new LoadAppsGuard(appsState.object);
}); });
it('should load apps', () => { it('should load apps', async () => {
appsState.setup(x => x.load()) appsState.setup(x => x.load())
.returns(() => of(null)); .returns(() => of(null));
let result = false; const result = await firstValueFrom(appGuard.canActivate());
appGuard.canActivate().subscribe(value => {
result = value;
});
expect(result).toBeTruthy(); expect(result).toBeTruthy();

10
frontend/app/shared/guards/load-languages.guard.spec.ts

@ -6,7 +6,7 @@
*/ */
import { LanguagesState } from '@app/shared'; import { LanguagesState } from '@app/shared';
import { of } from 'rxjs'; import { firstValueFrom, of } from 'rxjs';
import { IMock, Mock, Times } from 'typemoq'; import { IMock, Mock, Times } from 'typemoq';
import { LoadLanguagesGuard } from './load-languages.guard'; import { LoadLanguagesGuard } from './load-languages.guard';
@ -19,15 +19,11 @@ describe('LoadLanguagesGuard', () => {
languageGuard = new LoadLanguagesGuard(languagesState.object); languageGuard = new LoadLanguagesGuard(languagesState.object);
}); });
it('should load languages', () => { it('should load languages', async () => {
languagesState.setup(x => x.load()) languagesState.setup(x => x.load())
.returns(() => of(null)); .returns(() => of(null));
let result = false; const result = await firstValueFrom(languageGuard.canActivate());
languageGuard.canActivate().subscribe(value => {
result = value;
});
expect(result).toBeTruthy(); expect(result).toBeTruthy();

10
frontend/app/shared/guards/load-schemas.guard.spec.ts

@ -6,7 +6,7 @@
*/ */
import { SchemasState } from '@app/shared'; import { SchemasState } from '@app/shared';
import { of } from 'rxjs'; import { firstValueFrom, of } from 'rxjs';
import { IMock, Mock, Times } from 'typemoq'; import { IMock, Mock, Times } from 'typemoq';
import { LoadSchemasGuard } from './load-schemas.guard'; import { LoadSchemasGuard } from './load-schemas.guard';
@ -19,15 +19,11 @@ describe('LoadSchemasGuard', () => {
schemaGuard = new LoadSchemasGuard(schemasState.object); schemaGuard = new LoadSchemasGuard(schemasState.object);
}); });
it('should load schemas', () => { it('should load schemas', async () => {
schemasState.setup(x => x.load()) schemasState.setup(x => x.load())
.returns(() => of(null)); .returns(() => of(null));
let result = false; const result = await firstValueFrom(schemaGuard.canActivate());
schemaGuard.canActivate().subscribe(value => {
result = value;
});
expect(result).toBeTruthy(); expect(result).toBeTruthy();

28
frontend/app/shared/guards/must-be-authenticated.guard.spec.ts

@ -7,7 +7,7 @@
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { AuthService, UIOptions } from '@app/shared'; import { AuthService, UIOptions } from '@app/shared';
import { of } from 'rxjs'; import { firstValueFrom, of } from 'rxjs';
import { IMock, It, Mock, Times } from 'typemoq'; import { IMock, It, Mock, Times } from 'typemoq';
import { MustBeAuthenticatedGuard } from './must-be-authenticated.guard'; import { MustBeAuthenticatedGuard } from './must-be-authenticated.guard';
@ -23,51 +23,39 @@ describe('MustBeAuthenticatedGuard', () => {
authService = Mock.ofType<AuthService>(); authService = Mock.ofType<AuthService>();
}); });
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); const authGuard = new MustBeAuthenticatedGuard(uiOptions, authService.object, router.object);
authService.setup(x => x.userChanges) authService.setup(x => x.userChanges)
.returns(() => of(null)); .returns(() => of(null));
let result: boolean; const result = await firstValueFrom(authGuard.canActivate());
authGuard.canActivate().subscribe(x => { expect(result).toBeFalsy();
result = x;
});
expect(result!).toBeFalsy();
router.verify(x => x.navigate(['']), Times.once()); 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); const authGuard = new MustBeAuthenticatedGuard(uiOptions, authService.object, router.object);
authService.setup(x => x.userChanges) authService.setup(x => x.userChanges)
.returns(() => of(<any>{})); .returns(() => of(<any>{}));
let result: boolean; const result = await firstValueFrom(authGuard.canActivate());
authGuard.canActivate().subscribe(x => {
result = x;
});
expect(result!).toBeTruthy(); expect(result!).toBeTruthy();
router.verify(x => x.navigate(It.isAny()), Times.never()); 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); const authGuard = new MustBeAuthenticatedGuard(uiOptionsRedirect, authService.object, router.object);
authService.setup(x => x.userChanges) authService.setup(x => x.userChanges)
.returns(() => of(null)); .returns(() => of(null));
let result: boolean; const result = await firstValueFrom(authGuard.canActivate());
authGuard.canActivate().subscribe(x => {
result = x;
});
expect(result!).toBeFalsy(); expect(result!).toBeFalsy();

30
frontend/app/shared/guards/must-be-not-authenticated.guard.spec.ts

@ -7,7 +7,7 @@
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { AuthService, UIOptions } from '@app/shared'; import { AuthService, UIOptions } from '@app/shared';
import { of } from 'rxjs'; import { firstValueFrom, of } from 'rxjs';
import { IMock, It, Mock, Times } from 'typemoq'; import { IMock, It, Mock, Times } from 'typemoq';
import { MustBeNotAuthenticatedGuard } from './must-be-not-authenticated.guard'; import { MustBeNotAuthenticatedGuard } from './must-be-not-authenticated.guard';
@ -23,53 +23,41 @@ describe('MustBeNotAuthenticatedGuard', () => {
authService = Mock.ofType<AuthService>(); authService = Mock.ofType<AuthService>();
}); });
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); const authGuard = new MustBeNotAuthenticatedGuard(uiOptions, authService.object, router.object);
authService.setup(x => x.userChanges) authService.setup(x => x.userChanges)
.returns(() => of(<any>{})); .returns(() => of(<any>{}));
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(['app']), Times.once()); 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); const authGuard = new MustBeNotAuthenticatedGuard(uiOptions, authService.object, router.object);
authService.setup(x => x.userChanges) authService.setup(x => x.userChanges)
.returns(() => of(null)); .returns(() => of(null));
let result: boolean; const result = await firstValueFrom(authGuard.canActivate());
authGuard.canActivate().subscribe(x => {
result = x;
});
expect(result!).toBeTruthy(); expect(result).toBeTruthy();
router.verify(x => x.navigate(It.isAny()), Times.never()); 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); const authGuard = new MustBeNotAuthenticatedGuard(uiOptionsRedirect, authService.object, router.object);
authService.setup(x => x.userChanges) authService.setup(x => x.userChanges)
.returns(() => of(null)); .returns(() => of(null));
let result: boolean; const result = await firstValueFrom(authGuard.canActivate());
authGuard.canActivate().subscribe(x => { expect(result).toBeFalsy();
result = x;
});
expect(result!).toBeFalsy();
authService.verify(x => x.loginRedirect(), Times.once()); authService.verify(x => x.loginRedirect(), Times.once());
}); });

42
frontend/app/shared/guards/rule-must-exist.guard.spec.ts

@ -7,7 +7,7 @@
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { RuleDto, RulesState } from '@app/shared/internal'; import { RuleDto, RulesState } from '@app/shared/internal';
import { of } from 'rxjs'; import { firstValueFrom, of } from 'rxjs';
import { IMock, It, Mock, Times } from 'typemoq'; import { IMock, It, Mock, Times } from 'typemoq';
import { RuleMustExistGuard } from './rule-must-exist.guard'; import { RuleMustExistGuard } from './rule-must-exist.guard';
@ -22,86 +22,70 @@ describe('RuleMustExistGuard', () => {
ruleGuard = new RuleMustExistGuard(rulesState.object, router.object); 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')) rulesState.setup(x => x.select('123'))
.returns(() => of(<RuleDto>{})); .returns(() => of(<RuleDto>{}));
let result: boolean;
const route: any = { const route: any = {
params: { params: {
ruleId: '123', ruleId: '123',
}, },
}; };
ruleGuard.canActivate(route).subscribe(x => { const result = await firstValueFrom(ruleGuard.canActivate(route));
result = x;
}).unsubscribe();
expect(result!).toBeTruthy(); expect(result).toBeTruthy();
router.verify(x => x.navigate(It.isAny()), Times.never()); 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')) rulesState.setup(x => x.select('123'))
.returns(() => of(null)); .returns(() => of(null));
let result: boolean;
const route: any = { const route: any = {
params: { params: {
ruleId: '123', ruleId: '123',
}, },
}; };
ruleGuard.canActivate(route).subscribe(x => { const result = await firstValueFrom(ruleGuard.canActivate(route));
result = x;
}).unsubscribe();
expect(result!).toBeFalsy(); expect(result).toBeFalsy();
router.verify(x => x.navigate(['/404']), Times.once()); 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)) rulesState.setup(x => x.select(null))
.returns(() => of(null)); .returns(() => of(null));
let result: boolean;
const route: any = { const route: any = {
params: { params: {
ruleId: undefined, ruleId: undefined,
}, },
}; };
ruleGuard.canActivate(route).subscribe(x => { const result = await firstValueFrom(ruleGuard.canActivate(route));
result = x;
}).unsubscribe();
expect(result!).toBeTruthy(); expect(result).toBeTruthy();
rulesState.verify(x => x.select(null), Times.once()); rulesState.verify(x => x.select(null), Times.once());
}); });
it('should unset rule if rule id is <new>', () => { it('should unset rule if rule id is <new>', async () => {
rulesState.setup(x => x.select(null)) rulesState.setup(x => x.select(null))
.returns(() => of(null)); .returns(() => of(null));
let result: boolean;
const route: any = { const route: any = {
params: { params: {
ruleId: 'new', ruleId: 'new',
}, },
}; };
ruleGuard.canActivate(route).subscribe(x => { const result = await firstValueFrom(ruleGuard.canActivate(route));
result = x;
}).unsubscribe();
expect(result!).toBeTruthy(); expect(result).toBeTruthy();
rulesState.verify(x => x.select(null), Times.once()); rulesState.verify(x => x.select(null), Times.once());
}); });

42
frontend/app/shared/guards/schema-must-exist-published.guard.spec.ts

@ -7,7 +7,7 @@
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { SchemaDto, SchemasState } from '@app/shared/internal'; import { SchemaDto, SchemasState } from '@app/shared/internal';
import { of } from 'rxjs'; import { firstValueFrom, of } from 'rxjs';
import { IMock, It, Mock, Times } from 'typemoq'; import { IMock, It, Mock, Times } from 'typemoq';
import { SchemaMustExistPublishedGuard } from './schema-must-exist-published.guard'; import { SchemaMustExistPublishedGuard } from './schema-must-exist-published.guard';
@ -28,62 +28,46 @@ describe('SchemaMustExistPublishedGuard', () => {
schemaGuard = new SchemaMustExistPublishedGuard(schemasState.object, router.object); 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')) schemasState.setup(x => x.select('123'))
.returns(() => of(<SchemaDto>{ isPublished: true })); .returns(() => of(<SchemaDto>{ isPublished: true }));
let result: boolean; const result = await firstValueFrom(schemaGuard.canActivate(route));
schemaGuard.canActivate(route).subscribe(x => { expect(result).toBeTruthy();
result = x;
}).unsubscribe();
expect(result!).toBeTruthy();
router.verify(x => x.navigate(It.isAny()), Times.never()); 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')) schemasState.setup(x => x.select('123'))
.returns(() => of(<SchemaDto>{ isPublished: true, type: 'Component' })); .returns(() => of(<SchemaDto>{ isPublished: true, type: 'Component' }));
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()); 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')) schemasState.setup(x => x.select('123'))
.returns(() => of(null)); .returns(() => of(null));
let result: boolean; const result = await firstValueFrom(schemaGuard.canActivate(route));
schemaGuard.canActivate(route).subscribe(x => { expect(result).toBeFalsy();
result = x;
}).unsubscribe();
expect(result!).toBeFalsy();
router.verify(x => x.navigate(['/404']), Times.once()); 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')) schemasState.setup(x => x.select('123'))
.returns(() => of(<SchemaDto>{ isPublished: false, type: 'Default' })); .returns(() => of(<SchemaDto>{ isPublished: false, type: 'Default' }));
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()); router.verify(x => x.navigate(['/404']), Times.once());
}); });

22
frontend/app/shared/guards/schema-must-exist.guard.spec.ts

@ -7,7 +7,7 @@
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { SchemaDto, SchemasState } from '@app/shared/internal'; import { SchemaDto, SchemasState } from '@app/shared/internal';
import { of } from 'rxjs'; import { firstValueFrom, of } from 'rxjs';
import { IMock, Mock, Times } from 'typemoq'; import { IMock, Mock, Times } from 'typemoq';
import { SchemaMustExistGuard } from './schema-must-exist.guard'; import { SchemaMustExistGuard } from './schema-must-exist.guard';
@ -28,30 +28,22 @@ describe('SchemaMustExistGuard', () => {
schemaGuard = new SchemaMustExistGuard(schemasState.object, router.object); 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')) schemasState.setup(x => x.select('123'))
.returns(() => of(<SchemaDto>{})); .returns(() => of(<SchemaDto>{}));
let result: boolean; const result = await firstValueFrom(schemaGuard.canActivate(route));
schemaGuard.canActivate(route).subscribe(x => { expect(result).toBeTruthy();
result = x;
}).unsubscribe();
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')) schemasState.setup(x => x.select('123'))
.returns(() => of(null)); .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()); router.verify(x => x.navigate(['/404']), Times.once());
}); });

32
frontend/app/shared/guards/schema-must-not-be-singleton.guard.spec.ts

@ -7,7 +7,7 @@
import { Router, RouterStateSnapshot, UrlSegment } from '@angular/router'; import { Router, RouterStateSnapshot, UrlSegment } from '@angular/router';
import { SchemaDto, SchemasState } from '@app/shared/internal'; import { SchemaDto, SchemasState } from '@app/shared/internal';
import { of } from 'rxjs'; import { firstValueFrom, of } from 'rxjs';
import { IMock, It, Mock, Times } from 'typemoq'; import { IMock, It, Mock, Times } from 'typemoq';
import { SchemaMustNotBeSingletonGuard } from './schema-must-not-be-singleton.guard'; import { SchemaMustNotBeSingletonGuard } from './schema-must-not-be-singleton.guard';
@ -33,53 +33,41 @@ describe('SchemaMustNotBeSingletonGuard', () => {
schemaGuard = new SchemaMustNotBeSingletonGuard(schemasState.object, router.object); 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 = <any>{ url: 'schemas/name/' }; const state: RouterStateSnapshot = <any>{ url: 'schemas/name/' };
schemasState.setup(x => x.selectedSchema) schemasState.setup(x => x.selectedSchema)
.returns(() => of(<SchemaDto>{ id: '123', type: 'Default' })); .returns(() => of(<SchemaDto>{ id: '123', type: 'Default' }));
let result: boolean; const result = await firstValueFrom(schemaGuard.canActivate(route, state));
schemaGuard.canActivate(route, state).subscribe(x => { expect(result).toBeTruthy();
result = x;
}).unsubscribe();
expect(result!).toBeTruthy();
router.verify(x => x.navigate(It.isAny()), Times.never()); 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 = <any>{ url: 'schemas/name/' }; const state: RouterStateSnapshot = <any>{ url: 'schemas/name/' };
schemasState.setup(x => x.selectedSchema) schemasState.setup(x => x.selectedSchema)
.returns(() => of(<SchemaDto>{ id: '123', type: 'Singleton' })); .returns(() => of(<SchemaDto>{ id: '123', type: 'Singleton' }));
let result: boolean; const result = await firstValueFrom(schemaGuard.canActivate(route, state));
schemaGuard.canActivate(route, state).subscribe(x => {
result = x;
}).unsubscribe();
expect(result!).toBeFalsy(); expect(result).toBeFalsy();
router.verify(x => x.navigate([state.url, '123']), Times.once()); 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 = <any>{ url: 'schemas/name/new/' }; const state: RouterStateSnapshot = <any>{ url: 'schemas/name/new/' };
schemasState.setup(x => x.selectedSchema) schemasState.setup(x => x.selectedSchema)
.returns(() => of(<SchemaDto>{ id: '123', type: 'Singleton' })); .returns(() => of(<SchemaDto>{ id: '123', type: 'Singleton' }));
let result: boolean; const result = await firstValueFrom(schemaGuard.canActivate(route, state));
schemaGuard.canActivate(route, state).subscribe(x => {
result = x;
}).unsubscribe();
expect(result!).toBeFalsy(); expect(result).toBeFalsy();
router.verify(x => x.navigate(['schemas/name/', '123']), Times.once()); router.verify(x => x.navigate(['schemas/name/', '123']), Times.once());
}); });

10
frontend/app/shared/guards/unset-app.guard.spec.ts

@ -6,7 +6,7 @@
*/ */
import { AppsState } from '@app/shared/internal'; import { AppsState } from '@app/shared/internal';
import { of } from 'rxjs'; import { firstValueFrom, of } from 'rxjs';
import { IMock, Mock, Times } from 'typemoq'; import { IMock, Mock, Times } from 'typemoq';
import { UnsetAppGuard } from './unset-app.guard'; import { UnsetAppGuard } from './unset-app.guard';
@ -19,15 +19,11 @@ describe('UnsetAppGuard', () => {
appGuard = new UnsetAppGuard(appsState.object); appGuard = new UnsetAppGuard(appsState.object);
}); });
it('should unselect app', () => { it('should unselect app', async () => {
appsState.setup(x => x.select(null)) appsState.setup(x => x.select(null))
.returns(() => of(null)); .returns(() => of(null));
let result = false; const result = await firstValueFrom(appGuard.canActivate());
appGuard.canActivate().subscribe(value => {
result = value;
});
expect(result).toBeTruthy(); expect(result).toBeTruthy();

1
frontend/app/shared/internal.ts

@ -58,6 +58,7 @@ export * from './state/languages.state';
export * from './state/plans.state'; export * from './state/plans.state';
export * from './state/queries'; export * from './state/queries';
export * from './state/query'; export * from './state/query';
export * from './state/resolvers';
export * from './state/roles.forms'; export * from './state/roles.forms';
export * from './state/roles.state'; export * from './state/roles.state';
export * from './state/rule-events.state'; export * from './state/rule-events.state';

8
frontend/app/shared/module.ts

@ -12,7 +12,7 @@ import { RouterModule } from '@angular/router';
import { SqxFrameworkModule } from '@app/framework'; import { SqxFrameworkModule } from '@app/framework';
import { MentionModule } from 'angular-mentions'; import { MentionModule } from 'angular-mentions';
import { NgxDocViewerModule } from 'ngx-doc-viewer'; 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({ @NgModule({
imports: [ imports: [
@ -68,10 +68,7 @@ import { AppFormComponent, AppLanguagesService, AppMustExistGuard, AppsService,
QueryComponent, QueryComponent,
QueryListComponent, QueryListComponent,
QueryPathComponent, QueryPathComponent,
ReferenceDropdownComponent,
ReferenceInputComponent, ReferenceInputComponent,
ReferencesCheckboxesComponent,
ReferencesTagsComponent,
RichEditorComponent, RichEditorComponent,
SavedQueriesComponent, SavedQueriesComponent,
SchemaCategoryComponent, SchemaCategoryComponent,
@ -123,10 +120,7 @@ import { AppFormComponent, AppLanguagesService, AppMustExistGuard, AppsService,
NotifoComponent, NotifoComponent,
PreviewableType, PreviewableType,
QueryListComponent, QueryListComponent,
ReferenceDropdownComponent,
ReferenceInputComponent, ReferenceInputComponent,
ReferencesCheckboxesComponent,
ReferencesTagsComponent,
RichEditorComponent, RichEditorComponent,
RouterModule, RouterModule,
SavedQueriesComponent, SavedQueriesComponent,

55
frontend/app/shared/state/apps.state.spec.ts

@ -5,9 +5,8 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { AppDto, AppsService, AppsState, DialogService } from '@app/shared/internal'; import { AppsService, AppsState, DialogService } 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 { IMock, It, Mock, Times } from 'typemoq';
import { createApp, createAppSettings } from './../services/apps.service.spec'; import { createApp, createAppSettings } from './../services/apps.service.spec';
@ -43,14 +42,10 @@ describe('AppsState', () => {
expect(appsState.snapshot.apps).toEqual([app1, app2]); expect(appsState.snapshot.apps).toEqual([app1, app2]);
}); });
it('should select app', () => { it('should select app', async () => {
let selectedApp: AppDto; const appSelect = await firstValueFrom(appsState.select(app1.name));
appsState.select(app1.name).subscribe(x => { expect(appSelect).toBe(app1);
selectedApp = x!;
});
expect(selectedApp!).toBe(app1);
expect(appsState.snapshot.selectedApp).toBe(app1); expect(appsState.snapshot.selectedApp).toBe(app1);
expect(appsState.snapshot.selectedSettings).not.toBeNull(); expect(appsState.snapshot.selectedSettings).not.toBeNull();
@ -74,47 +69,35 @@ describe('AppsState', () => {
expect().nothing(); expect().nothing();
}); });
it('should return null on select if unselecting app', () => { it('should return null on select if unselecting app', async () => {
let appSelected: AppDto; const appSelected = await firstValueFrom(appsState.select(null));
appsState.select(null).subscribe(x => { expect(appSelected).toBeNull();
appSelected = x!;
});
expect(appSelected!).toBeNull();
expect(appsState.snapshot.selectedApp).toBeNull(); expect(appsState.snapshot.selectedApp).toBeNull();
expect(appsState.snapshot.selectedSettings).toBeNull(); expect(appsState.snapshot.selectedSettings).toBeNull();
appsService.verify(x => x.getSettings(It.isAnyString()), Times.never()); appsService.verify(x => x.getSettings(It.isAnyString()), Times.never());
}); });
it('should return new app if loaded', () => { it('should return null on select if app is not found', 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));
let appSelected: AppDto;
appsState.loadApp(app1.name).subscribe(x => { const appSelected = await firstValueFrom(appsState.select('unknown'));
appSelected = x!;
});
expect(appSelected!).toEqual(newApp); expect(appSelected).toBeNull();
expect(appsState.snapshot.selectedApp).toBeNull(); expect(appsState.snapshot.selectedApp).toBeNull();
}); });
it('should return null on select if app is not found', () => { it('should return new app if loaded', async () => {
let appSelected: AppDto; const newApp = createApp(1, '_new');
appsService.setup(x => x.getApp('unknown')) appsService.setup(x => x.getApp(app1.name))
.returns(() => throwError(() => 'Service Error')); .returns(() => of(newApp));
appsState.select('unknown').pipe(onErrorResumeNext()).subscribe(x => { const appSelected = await firstValueFrom(appsState.loadApp(app1.name));
appSelected = x!;
});
expect(appSelected!).toBeNull(); expect(appSelected).toEqual(newApp);
expect(appsState.snapshot.selectedApp).toBeNull(); expect(appsState.snapshot.selectedApp).toBeNull();
}); });

32
frontend/app/shared/state/asset-uploader.state.spec.ts

@ -5,8 +5,8 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { AssetDto, AssetsService, AssetsState, AssetUploaderState, DialogService, ofForever, Types } from '@app/shared/internal'; import { AssetsService, AssetsState, AssetUploaderState, DialogService, ofForever } from '@app/shared/internal';
import { NEVER, of, throwError } from 'rxjs'; import { lastValueFrom, NEVER, of, throwError } from 'rxjs';
import { onErrorResumeNext } from 'rxjs/operators'; import { onErrorResumeNext } from 'rxjs/operators';
import { IMock, Mock } from 'typemoq'; import { IMock, Mock } from 'typemoq';
import { createAsset } from './../services/assets.service.spec'; import { createAsset } from './../services/assets.service.spec';
@ -93,27 +93,19 @@ describe('AssetUploaderState', () => {
expect(upload.progress).toBe(1); 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 = <any>{ name: 'my-file' }; const file: File = <any>{ name: 'my-file' };
assetsService.setup(x => x.postAssetFile(app, file, undefined)) assetsService.setup(x => x.postAssetFile(app, file, undefined))
.returns(() => of(10, 20, asset)).verifiable(); .returns(() => of(10, 20, asset)).verifiable();
let uploadedAsset: AssetDto; const uploadedAsset = await lastValueFrom(assetUploader.uploadFile(file));
assetUploader.uploadFile(file).subscribe(dto => {
if (Types.is(dto, AssetDto)) {
uploadedAsset = dto;
}
cb();
});
const upload = assetUploader.snapshot.uploads[0]; const upload = assetUploader.snapshot.uploads[0];
expect(upload.status).toBe('Completed'); expect(upload.status).toBe('Completed');
expect(upload.progress).toBe(100); expect(upload.progress).toBe(100);
expect(uploadedAsset!).toEqual(asset); expect(uploadedAsset).toEqual(asset);
}); });
it('should create initial state if uploading asset', () => { it('should create initial state if uploading asset', () => {
@ -145,7 +137,7 @@ describe('AssetUploaderState', () => {
}); });
it('should update status if uploading asset failed', () => { it('should update status if uploading asset failed', () => {
const file: File = <any>{ name: 'my-file' }; const file: File = { name: 'my-file' } as any;
assetsService.setup(x => x.putAssetFile(app, asset, file, asset.version)) assetsService.setup(x => x.putAssetFile(app, asset, file, asset.version))
.returns(() => throwError(() => 'Service Error')).verifiable(); .returns(() => throwError(() => 'Service Error')).verifiable();
@ -158,21 +150,15 @@ describe('AssetUploaderState', () => {
expect(upload.progress).toBe(1); expect(upload.progress).toBe(1);
}); });
it('should update status if uploading asset completes', () => { it('should update status if uploading asset completes', async () => {
const file: File = <any>{ name: 'my-file' }; const file: File = { name: 'my-file' } as any;
const updated = createAsset(1, undefined, '_new'); const updated = createAsset(1, undefined, '_new');
assetsService.setup(x => x.putAssetFile(app, asset, file, asset.version)) assetsService.setup(x => x.putAssetFile(app, asset, file, asset.version))
.returns(() => of(10, 20, updated)).verifiable(); .returns(() => of(10, 20, updated)).verifiable();
let uploadedAsset: AssetDto; const uploadedAsset = await lastValueFrom(assetUploader.uploadAsset(asset, file));
assetUploader.uploadAsset(asset, file).subscribe(dto => {
if (Types.is(dto, AssetDto)) {
uploadedAsset = dto;
}
});
const upload = assetUploader.snapshot.uploads[0]; const upload = assetUploader.snapshot.uploads[0];

4
frontend/app/shared/state/contents.forms.spec.ts

@ -804,8 +804,8 @@ describe('ContentForm', () => {
let value: any; let value: any;
simpleForm.valueChanges.subscribe(v => { simpleForm.valueChanges.subscribe(result => {
value = v; value = result;
}); });
expect(value).toEqual({ field1: { iv: 'Change' } }); expect(value).toEqual({ field1: { iv: 'Change' } });

209
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<ContentsService>;
let contentsResolver: ResolveContents;
const contents = [
createContent(1),
createContent(2),
createContent(3),
createContent(4),
];
beforeEach(() => {
contentsService = Mock.ofType<ContentsService>();
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],
]);
});
});

206
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<T extends { id: string }, TResult extends { items: ReadonlyArray<T> }> {
private readonly items: { [id: string]: Deferred<T | undefined> } = {};
private pending: { [id: string]: boolean } | null = null;
public resolveMany(ids: ReadonlyArray<string>): Observable<TResult> {
if (ids.length === 0) {
return of(this.createResult([]));
}
const nonResolved: string[] = [];
const promises: Promise<T | undefined>[] = [];
for (const id of ids) {
let deferred = this.items[id];
if (!deferred) {
deferred = new Deferred<T>();
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<T | undefined>[]) {
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<TResult>;
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<ContentDto, ContentsDto> {
private readonly schemas: { [name: string]: Observable<ContentsDto> } = {};
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<AssetDto, AssetsDto> {
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<T>(array: T[], size: number): T[][] {
if (array.length > size) {
return [array.slice(0, size), ...chunkArray(array.slice(size), size)];
} else {
return [array];
}
}
class Deferred<T> {
private handleResolve: Function;
private handleReject: Function;
private isHandled = false;
public readonly promise: Promise<T>;
constructor() {
this.promise = new Promise<T>((resolve, reject) => {
this.handleResolve = resolve;
this.handleReject = reject;
});
}
public resolve(value: T | PromiseLike<T>) {
if (this.isHandled) {
return;
}
this.isHandled = true;
this.handleResolve(value);
}
public reject(reason?: any) {
if (this.isHandled) {
return;
}
this.isHandled = true;
this.handleReject(reason);
}
}

28
frontend/app/shared/state/rules.state.spec.ts

@ -6,7 +6,7 @@
*/ */
import { DialogService, RulesDto, RulesService, versioned } from '@app/shared/internal'; 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 { onErrorResumeNext } from 'rxjs/operators';
import { IMock, It, Mock, Times } from 'typemoq'; import { IMock, It, Mock, Times } from 'typemoq';
import { RuleDto } from './../services/rules.service'; import { RuleDto } from './../services/rules.service';
@ -52,13 +52,13 @@ describe('RulesState', () => {
expect(rulesState.snapshot.isLoading).toBeFalsy(); expect(rulesState.snapshot.isLoading).toBeFalsy();
expect(rulesState.snapshot.rules).toEqual([rule1, rule2]); expect(rulesState.snapshot.rules).toEqual([rule1, rule2]);
let runningRule: RuleDto | undefined; let ruleRunning: RuleDto | undefined;
rulesState.runningRule.subscribe(result => { rulesState.runningRule.subscribe(result => {
runningRule = result; ruleRunning = result;
}); });
expect(runningRule).toBe(rule1); expect(ruleRunning).toBe(rule1);
expect(rulesState.snapshot.runningRuleId).toBe(rule1.id); expect(rulesState.snapshot.runningRuleId).toBe(rule1.id);
dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never()); dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never());
@ -93,25 +93,17 @@ describe('RulesState', () => {
rulesState.load().subscribe(); rulesState.load().subscribe();
}); });
it('should return rule on select and not load if already loaded', () => { it('should return rule on select and not load if already loaded', async () => {
let ruleSelected: RuleDto; const ruleSelected = await firstValueFrom(rulesState.select(rule1.id));
rulesState.select(rule1.id).subscribe(x => { expect(ruleSelected).toEqual(rule1);
ruleSelected = x!;
});
expect(ruleSelected!).toEqual(rule1);
expect(rulesState.snapshot.selectedRule).toEqual(rule1); expect(rulesState.snapshot.selectedRule).toEqual(rule1);
}); });
it('should return null on select if unselecting rule', () => { it('should return null on select if unselecting rule', async () => {
let ruleSelected: RuleDto; const ruleSelected = await firstValueFrom(rulesState.select(null));
rulesState.select(null).subscribe(x => {
ruleSelected = x!;
});
expect(ruleSelected!).toBeNull(); expect(ruleSelected).toBeNull();
expect(rulesState.snapshot.selectedRule).toBeNull(); expect(rulesState.snapshot.selectedRule).toBeNull();
}); });

44
frontend/app/shared/state/schemas.state.spec.ts

@ -5,8 +5,8 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { DialogService, FieldDto, SchemaDto, SchemasService, UpdateSchemaCategoryDto, versioned } from '@app/shared/internal'; import { DialogService, SchemaDto, SchemasService, UpdateSchemaCategoryDto, versioned } from '@app/shared/internal';
import { of, throwError } from 'rxjs'; import { firstValueFrom, of, throwError } from 'rxjs';
import { onErrorResumeNext } from 'rxjs/operators'; import { onErrorResumeNext } from 'rxjs/operators';
import { IMock, It, Mock, Times } from 'typemoq'; import { IMock, It, Mock, Times } from 'typemoq';
import { createSchema } from './../services/schemas.service.spec'; import { createSchema } from './../services/schemas.service.spec';
@ -151,29 +151,21 @@ describe('SchemasState', () => {
expect().nothing(); 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)) schemasService.setup(x => x.getSchema(app, schema1.name))
.returns(() => of(schema1)).verifiable(); .returns(() => of(schema1)).verifiable();
let schemaSelected: SchemaDto; const schemaSelected = await firstValueFrom(schemasState.select(schema1.name));
schemasState.select(schema1.name).subscribe(x => { expect(schemaSelected).toBe(schema1);
schemaSelected = x!;
});
expect(schemaSelected!).toBe(schema1);
expect(schemasState.snapshot.selectedSchema).toBe(schema1); expect(schemasState.snapshot.selectedSchema).toBe(schema1);
expect(schemasState.snapshot.selectedSchema).toBe(<SchemaDto>schemasState.snapshot.schemas[0]); expect(schemasState.snapshot.selectedSchema).toBe(<SchemaDto>schemasState.snapshot.schemas[0]);
}); });
it('should return null on select if unselecting schema', () => { it('should return null on select if unselecting schema', async () => {
let schemaSelected: SchemaDto; const schemaSelected = await firstValueFrom(schemasState.select(null));
schemasState.select(null).subscribe(x => {
schemaSelected = x!;
});
expect(schemaSelected!).toBeNull(); expect(schemaSelected).toBeNull();
expect(schemasState.snapshot.selectedSchema).toBeNull(); expect(schemasState.snapshot.selectedSchema).toBeNull();
}); });
@ -325,7 +317,7 @@ describe('SchemasState', () => {
expect(schemasState.snapshot.selectedSchema).toBeNull(); 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 request = { ...schema1.fields[0] };
const updated = createSchema(1, '_new'); const updated = createSchema(1, '_new');
@ -333,18 +325,14 @@ describe('SchemasState', () => {
schemasService.setup(x => x.postField(app, schema1, It.isAny(), version)) schemasService.setup(x => x.postField(app, schema1, It.isAny(), version))
.returns(() => of(updated)).verifiable(); .returns(() => of(updated)).verifiable();
let newField: FieldDto; const schemaField = await firstValueFrom(schemasState.addField(schema1, request));
schemasState.addField(schema1, request).subscribe(result => {
newField = result;
});
expect(schemaField).toBeDefined();
expect(schemasState.snapshot.schemas).toEqual([updated, schema2]); expect(schemasState.snapshot.schemas).toEqual([updated, schema2]);
expect(schemasState.snapshot.selectedSchema).toEqual(updated); 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 request = { ...schema1.fields[0].nested[0] };
const updated = createSchema(1, '_new'); const updated = createSchema(1, '_new');
@ -352,15 +340,11 @@ describe('SchemasState', () => {
schemasService.setup(x => x.postField(app, schema1.fields[0], It.isAny(), version)) schemasService.setup(x => x.postField(app, schema1.fields[0], It.isAny(), version))
.returns(() => of(updated)).verifiable(); .returns(() => of(updated)).verifiable();
let newField: FieldDto; const schemaField = await firstValueFrom(schemasState.addField(schema1, request, schema1.fields[0]));
schemasState.addField(schema1, request, schema1.fields[0]).subscribe(result => {
newField = result;
});
expect(schemaField).toBeDefined();
expect(schemasState.snapshot.schemas).toEqual([updated, schema2]); expect(schemasState.snapshot.schemas).toEqual([updated, schema2]);
expect(schemasState.snapshot.selectedSchema).toEqual(updated); expect(schemasState.snapshot.selectedSchema).toEqual(updated);
expect(newField!).toBeDefined();
}); });
it('should update schema and selected schema if field removed', () => { it('should update schema and selected schema if field removed', () => {

2
frontend/app/shared/state/table-fields.spec.ts

@ -38,7 +38,7 @@ describe('TableFields', () => {
]; ];
INVALID_CONFIGS.forEach(test => { 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<TableField>; let fields: ReadonlyArray<TableField>;
let fieldNames: ReadonlyArray<string>; let fieldNames: ReadonlyArray<string>;

Loading…
Cancel
Save