Browse Source

Feature/route sync (#535)

* Sync state with routes
pull/537/head
Sebastian Stehle 6 years ago
committed by GitHub
parent
commit
3b479d0c40
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 9
      backend/src/Squidex/Areas/Api/Controllers/Assets/AssetFoldersController.cs
  2. 13
      backend/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetFoldersDto.cs
  3. 2
      frontend/app/features/administration/pages/users/user.component.html
  4. 16
      frontend/app/features/administration/pages/users/users-page.component.ts
  5. 44
      frontend/app/features/administration/state/users.state.spec.ts
  6. 25
      frontend/app/features/administration/state/users.state.ts
  7. 19
      frontend/app/features/assets/pages/assets-page.component.ts
  8. 55
      frontend/app/features/content/pages/contents/contents-page.component.ts
  9. 2
      frontend/app/features/content/shared/forms/array-editor.component.ts
  10. 4
      frontend/app/features/content/shared/forms/array-item.component.ts
  11. 2
      frontend/app/features/content/shared/list/content-list-cell.directive.ts
  12. 23
      frontend/app/features/rules/pages/events/rule-events-page.component.ts
  13. 2
      frontend/app/features/settings/pages/contributors/contributors-page.component.html
  14. 10
      frontend/app/features/settings/pages/contributors/contributors-page.component.ts
  15. 2
      frontend/app/framework/angular/forms/editors/checkbox-group.component.ts
  16. 4
      frontend/app/framework/angular/forms/editors/toggle.component.ts
  17. 2
      frontend/app/framework/angular/modals/modal-placement.directive.ts
  18. 2
      frontend/app/framework/angular/pipes/colors.pipes.ts
  19. 8
      frontend/app/framework/angular/pipes/numbers.pipes.ts
  20. 337
      frontend/app/framework/angular/routers/router-2-state.spec.ts
  21. 299
      frontend/app/framework/angular/routers/router-2-state.ts
  22. 2
      frontend/app/framework/angular/shortcut.component.spec.ts
  23. 1
      frontend/app/framework/declarations.ts
  24. 2
      frontend/app/framework/services/loading.service.spec.ts
  25. 2
      frontend/app/framework/state.ts
  26. 2
      frontend/app/framework/utils/modal-positioner.spec.ts
  27. 25
      frontend/app/framework/utils/pager.spec.ts
  28. 21
      frontend/app/framework/utils/pager.ts
  29. 2
      frontend/app/framework/utils/tag-values.ts
  30. 4
      frontend/app/framework/utils/types.ts
  31. 4
      frontend/app/shared/components/assets/assets-list.component.html
  32. 2
      frontend/app/shared/components/assets/image-focus-point.component.ts
  33. 4
      frontend/app/shared/components/search/search-form.component.ts
  34. 4
      frontend/app/shared/guards/must-be-authenticated.guard.spec.ts
  35. 4
      frontend/app/shared/guards/must-be-not-authenticated.guard.spec.ts
  36. 5
      frontend/app/shared/services/assets.service.spec.ts
  37. 16
      frontend/app/shared/services/assets.service.ts
  38. 4
      frontend/app/shared/services/autosave.service.ts
  39. 2
      frontend/app/shared/services/contents.service.ts
  40. 2
      frontend/app/shared/services/rules.service.ts
  41. 4
      frontend/app/shared/services/schemas.service.ts
  42. 2
      frontend/app/shared/services/usages.service.ts
  43. 10
      frontend/app/shared/services/workflows.service.ts
  44. 57
      frontend/app/shared/state/_test-helpers.ts
  45. 2
      frontend/app/shared/state/asset-uploader.state.spec.ts
  46. 2
      frontend/app/shared/state/assets.forms.ts
  47. 70
      frontend/app/shared/state/assets.state.spec.ts
  48. 116
      frontend/app/shared/state/assets.state.ts
  49. 2
      frontend/app/shared/state/contents.forms.spec.ts
  50. 40
      frontend/app/shared/state/contents.state.ts
  51. 34
      frontend/app/shared/state/contributors.state.spec.ts
  52. 27
      frontend/app/shared/state/contributors.state.ts
  53. 6
      frontend/app/shared/state/queries.ts
  54. 43
      frontend/app/shared/state/query.ts
  55. 27
      frontend/app/shared/state/rule-events.state.spec.ts
  56. 15
      frontend/app/shared/state/rule-events.state.ts
  57. 2
      frontend/app/shared/state/ui.state.ts
  58. 1
      frontend/package.json
  59. 1
      frontend/tslint.json

9
backend/src/Squidex/Areas/Api/Controllers/Assets/AssetFoldersController.cs

@ -51,14 +51,17 @@ namespace Squidex.Areas.Api.Controllers.Assets
[ApiCosts(1)] [ApiCosts(1)]
public async Task<IActionResult> GetAssetFolders(string app, [FromQuery] Guid parentId) public async Task<IActionResult> GetAssetFolders(string app, [FromQuery] Guid parentId)
{ {
var assetFolders = await assetQuery.QueryAssetFoldersAsync(Context, parentId); var assetFolders = assetQuery.QueryAssetFoldersAsync(Context, parentId);
var assetPath = assetQuery.FindAssetFolderAsync(parentId);
await Task.WhenAll(assetFolders, assetPath);
var response = Deferred.Response(() => var response = Deferred.Response(() =>
{ {
return AssetFoldersDto.FromAssets(assetFolders, Resources); return AssetFoldersDto.FromAssets(assetFolders.Result, assetPath.Result, Resources);
}); });
Response.Headers[HeaderNames.ETag] = assetFolders.ToEtag(); Response.Headers[HeaderNames.ETag] = assetFolders.Result.ToEtag();
return Ok(response); return Ok(response);
} }

13
backend/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetFoldersDto.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Linq; using System.Linq;
using Squidex.Domain.Apps.Entities.Assets; using Squidex.Domain.Apps.Entities.Assets;
@ -26,14 +27,22 @@ namespace Squidex.Areas.Api.Controllers.Assets.Models
[Required] [Required]
public AssetFolderDto[] Items { get; set; } public AssetFolderDto[] Items { get; set; }
public static AssetFoldersDto FromAssets(IResultList<IAssetFolderEntity> assetFolders, Resources resources) /// <summary>
/// The path to the current folder.
/// </summary>
[Required]
public AssetFolderDto[] Path { get; set; }
public static AssetFoldersDto FromAssets(IResultList<IAssetFolderEntity> assetFolders, IEnumerable<IAssetFolderEntity> path, Resources resources)
{ {
var response = new AssetFoldersDto var response = new AssetFoldersDto
{ {
Total = assetFolders.Total, Total = assetFolders.Total,
Items = assetFolders.Select(x => AssetFolderDto.FromAssetFolder(x, resources)).ToArray() Items = assetFolders.Select(x => AssetFolderDto.FromAssetFolder(x, resources)).ToArray(),
}; };
response.Path = path.Select(x => AssetFolderDto.FromAssetFolder(x, resources)).ToArray();
return CreateLinks(response, resources); return CreateLinks(response, resources);
} }

2
frontend/app/features/administration/pages/users/user.component.html

@ -1,4 +1,4 @@
<tr [routerLink]="user.id" routerLinkActive="active"> <tr [routerLink]="user.id" queryParamsHandling="merge" routerLinkActive="active">
<td class="cell-user"> <td class="cell-user">
<img class="user-picture" title="{{user.displayName}}" [src]="user | sqxUserDtoPicture" /> <img class="user-picture" title="{{user.displayName}}" [src]="user | sqxUserDtoPicture" />
</td> </td>

16
frontend/app/features/administration/pages/users/users-page.component.ts

@ -8,22 +8,32 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { FormControl } from '@angular/forms'; import { FormControl } from '@angular/forms';
import { UserDto, UsersState } from '@app/features/administration/internal'; import { UserDto, UsersState } from '@app/features/administration/internal';
import { ResourceOwner, Router2State } from '@app/framework';
@Component({ @Component({
selector: 'sqx-users-page', selector: 'sqx-users-page',
styleUrls: ['./users-page.component.scss'], styleUrls: ['./users-page.component.scss'],
templateUrl: './users-page.component.html' templateUrl: './users-page.component.html',
providers: [
Router2State
]
}) })
export class UsersPageComponent implements OnInit { export class UsersPageComponent extends ResourceOwner implements OnInit {
public usersFilter = new FormControl(); public usersFilter = new FormControl();
constructor( constructor(
public readonly usersRoute: Router2State,
public readonly usersState: UsersState public readonly usersState: UsersState
) { ) {
super();
this.own(
this.usersState.usersQuery
.subscribe(q => this.usersFilter.setValue(q || '')));
} }
public ngOnInit() { public ngOnInit() {
this.usersState.load(); this.usersState.loadAndListen(this.usersRoute);
} }
public reload() { public reload() {

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

@ -6,14 +6,17 @@
*/ */
import { UserDto, UsersDto, UsersService } from '@app/features/administration/internal'; import { UserDto, UsersDto, UsersService } from '@app/features/administration/internal';
import { DialogService, LocalStoreService, Pager } from '@app/shared'; import { DialogService, Pager } from '@app/shared';
import { of, throwError } from 'rxjs'; import { 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 { TestValues } from './../../../shared/state/_test-helpers';
import { createUser } from './../services/users.service.spec'; import { createUser } from './../services/users.service.spec';
import { UsersState } from './users.state'; import { UsersState } from './users.state';
describe('UsersState', () => { describe('UsersState', () => {
const { buildDummyStateSynchronizer } = TestValues;
const user1 = createUser(1); const user1 = createUser(1);
const user2 = createUser(2); const user2 = createUser(2);
@ -22,17 +25,14 @@ describe('UsersState', () => {
const newUser = createUser(3); const newUser = createUser(3);
let dialogs: IMock<DialogService>; let dialogs: IMock<DialogService>;
let localStore: IMock<LocalStoreService>;
let usersService: IMock<UsersService>; let usersService: IMock<UsersService>;
let usersState: UsersState; let usersState: UsersState;
beforeEach(() => { beforeEach(() => {
dialogs = Mock.ofType<DialogService>(); dialogs = Mock.ofType<DialogService>();
localStore = Mock.ofType<LocalStoreService>();
usersService = Mock.ofType<UsersService>(); usersService = Mock.ofType<UsersService>();
usersState = new UsersState(dialogs.object, localStore.object, usersService.object); usersState = new UsersState(dialogs.object, usersService.object);
}); });
afterEach(() => { afterEach(() => {
@ -63,15 +63,6 @@ describe('UsersState', () => {
expect(usersState.snapshot.isLoading).toBeFalsy(); expect(usersState.snapshot.isLoading).toBeFalsy();
}); });
it('should load page size from local store', () => {
localStore.setup(x => x.getInt('users.pageSize', 10))
.returns(() => 25);
const state = new UsersState(dialogs.object, localStore.object, usersService.object);
expect(state.snapshot.usersPager.pageSize).toBe(25);
});
it('should show notification on load when reload is true', () => { it('should show notification on load when reload is true', () => {
usersService.setup(x => x.getUsers(10, 0, undefined)) usersService.setup(x => x.getUsers(10, 0, undefined))
.returns(() => of(oldUsers)).verifiable(); .returns(() => of(oldUsers)).verifiable();
@ -111,17 +102,6 @@ describe('UsersState', () => {
expect().nothing(); expect().nothing();
}); });
it('should update page size in local store', () => {
usersService.setup(x => x.getUsers(50, 0, undefined))
.returns(() => of(new UsersDto(200, []))).verifiable();
usersState.setPager(new Pager(0, 0, 50));
localStore.verify(x => x.setInt('users.pageSize', 50), Times.atLeastOnce());
expect().nothing();
});
it('should load with query when searching', () => { it('should load with query when searching', () => {
usersService.setup(x => x.getUsers(10, 0, 'my-query')) usersService.setup(x => x.getUsers(10, 0, 'my-query'))
.returns(() => of(new UsersDto(0, []))).verifiable(); .returns(() => of(new UsersDto(0, []))).verifiable();
@ -130,6 +110,20 @@ describe('UsersState', () => {
expect(usersState.snapshot.usersQuery).toEqual('my-query'); expect(usersState.snapshot.usersQuery).toEqual('my-query');
}); });
it('should load when synchronizer triggered', () => {
const { synchronizer, trigger } = buildDummyStateSynchronizer();
usersService.setup(x => x.getUsers(10, 0, undefined))
.returns(() => of(oldUsers)).verifiable(Times.exactly(2));
usersState.loadAndListen(synchronizer);
trigger();
trigger();
expect().nothing();
});
}); });
describe('Updates', () => { describe('Updates', () => {

25
frontend/app/features/administration/state/users.state.ts

@ -7,7 +7,7 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import '@app/framework/utils/rxjs-extensions'; import '@app/framework/utils/rxjs-extensions';
import { DialogService, LocalStoreService, Pager, shareSubscribed, State } from '@app/shared'; import { DialogService, Pager, shareSubscribed, State, StateSynchronizer } from '@app/shared';
import { Observable, of } from 'rxjs'; import { Observable, of } from 'rxjs';
import { catchError, finalize, tap } from 'rxjs/operators'; import { catchError, finalize, tap } from 'rxjs/operators';
import { CreateUserDto, UpdateUserDto, UserDto, UsersService } from './../services/users.service'; import { CreateUserDto, UpdateUserDto, UserDto, UsersService } from './../services/users.service';
@ -46,6 +46,9 @@ export class UsersState extends State<Snapshot> {
public usersPager = public usersPager =
this.project(x => x.usersPager); this.project(x => x.usersPager);
public usersQuery =
this.project(x => x.usersQuery);
public selectedUser = public selectedUser =
this.project(x => x.selectedUser); this.project(x => x.selectedUser);
@ -60,16 +63,11 @@ export class UsersState extends State<Snapshot> {
constructor( constructor(
private readonly dialogs: DialogService, private readonly dialogs: DialogService,
private readonly localStore: LocalStoreService,
private readonly usersService: UsersService private readonly usersService: UsersService
) { ) {
super({ super({
users: [], users: [],
usersPager: Pager.fromLocalStore('users', localStore) usersPager: new Pager(0)
});
this.usersPager.subscribe(pager => {
pager.saveTo('users', this.localStore);
}); });
} }
@ -95,11 +93,18 @@ export class UsersState extends State<Snapshot> {
return this.usersService.getUser(id).pipe(catchError(() => of(null))); return this.usersService.getUser(id).pipe(catchError(() => of(null)));
} }
public loadAndListen(synchronizer: StateSynchronizer) {
synchronizer.mapTo(this)
.keep('selectedUser')
.withPager('usersPager', 'users', 10)
.withString('usersQuery', 'q')
.whenSynced(() => this.loadInternal(false))
.build();
}
public load(isReload = false): Observable<any> { public load(isReload = false): Observable<any> {
if (!isReload) { if (!isReload) {
const usersPager = this.snapshot.usersPager.reset(); this.resetState({ selectedUser: this.snapshot.selectedUser });
this.resetState({ usersPager, selectedUser: this.snapshot.selectedUser });
} }
return this.loadInternal(isReload); return this.loadInternal(isReload);

19
frontend/app/features/assets/pages/assets-page.component.ts

@ -6,18 +6,17 @@
*/ */
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { FormControl } from '@angular/forms'; import { AssetsState, DialogModel, LocalStoreService, Queries, Query, ResourceOwner, Router2State, UIState } from '@app/shared';
import { ActivatedRoute } from '@angular/router';
import { AssetsState, DialogModel, LocalStoreService, Queries, Query, ResourceOwner, UIState } from '@app/shared';
@Component({ @Component({
selector: 'sqx-assets-page', selector: 'sqx-assets-page',
styleUrls: ['./assets-page.component.scss'], styleUrls: ['./assets-page.component.scss'],
templateUrl: './assets-page.component.html' templateUrl: './assets-page.component.html',
providers: [
Router2State
]
}) })
export class AssetsPageComponent extends ResourceOwner implements OnInit { export class AssetsPageComponent extends ResourceOwner implements OnInit {
public assetsFilter = new FormControl();
public queries = new Queries(this.uiState, 'assets'); public queries = new Queries(this.uiState, 'assets');
public addAssetFolderDialog = new DialogModel(); public addAssetFolderDialog = new DialogModel();
@ -25,9 +24,9 @@ export class AssetsPageComponent extends ResourceOwner implements OnInit {
public isListView: boolean; public isListView: boolean;
constructor( constructor(
public readonly assetsRoute: Router2State,
public readonly assetsState: AssetsState, public readonly assetsState: AssetsState,
private readonly localStore: LocalStoreService, private readonly localStore: LocalStoreService,
private readonly route: ActivatedRoute,
private readonly uiState: UIState private readonly uiState: UIState
) { ) {
super(); super();
@ -36,11 +35,7 @@ export class AssetsPageComponent extends ResourceOwner implements OnInit {
} }
public ngOnInit() { public ngOnInit() {
this.own( this.assetsState.loadAndListen(this.assetsRoute);
this.route.queryParams
.subscribe(p => {
this.assetsState.search({ fullText: p['query'] });
}));
} }
public reload() { public reload() {

55
frontend/app/features/content/pages/contents/contents-page.component.ts

@ -5,16 +5,22 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
// tslint:disable: max-line-length
import { Component, OnInit, ViewChild } from '@angular/core'; import { Component, OnInit, ViewChild } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { AppLanguageDto, ContentDto, ContentsState, fadeAnimation, LanguagesState, ModalModel, Queries, Query, QueryModel, queryModelFromSchema, ResourceOwner, SchemaDetailsDto, SchemasState, TableFields, TempService, UIState } from '@app/shared'; import { AppLanguageDto, ContentDto, ContentsState, fadeAnimation, LanguagesState, ModalModel, Queries, Query, QueryModel, queryModelFromSchema, ResourceOwner, Router2State, SchemaDetailsDto, SchemasState, TableFields, TempService, UIState } from '@app/shared';
import { onErrorResumeNext, switchMap, tap } from 'rxjs/operators'; import { combineLatest } from 'rxjs';
import { distinctUntilChanged, onErrorResumeNext, switchMap, tap } from 'rxjs/operators';
import { DueTimeSelectorComponent } from './../../shared/due-time-selector.component'; import { DueTimeSelectorComponent } from './../../shared/due-time-selector.component';
@Component({ @Component({
selector: 'sqx-contents-page', selector: 'sqx-contents-page',
styleUrls: ['./contents-page.component.scss'], styleUrls: ['./contents-page.component.scss'],
templateUrl: './contents-page.component.html', templateUrl: './contents-page.component.html',
providers: [
Router2State
],
animations: [ animations: [
fadeAnimation fadeAnimation
] ]
@ -45,6 +51,7 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit {
public dueTimeSelector: DueTimeSelectorComponent; public dueTimeSelector: DueTimeSelectorComponent;
constructor( constructor(
public readonly contentsRoute: Router2State,
public readonly contentsState: ContentsState, public readonly contentsState: ContentsState,
private readonly route: ActivatedRoute, private readonly route: ActivatedRoute,
private readonly router: Router, private readonly router: Router,
@ -58,23 +65,26 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit {
public ngOnInit() { public ngOnInit() {
this.own( this.own(
this.schemasState.selectedSchema combineLatest(
this.schemasState.selectedSchema,
this.languagesState.languages,
this.contentsState.statuses
).subscribe(([schema, languages, statuses]) => {
this.queryModel = queryModelFromSchema(schema, languages.map(x => x.language), statuses);
}));
this.own(
this.route.params.pipe(
switchMap(x => this.schemasState.selectedSchema), distinctUntilChanged())
.subscribe(schema => { .subscribe(schema => {
this.resetSelection(); this.resetSelection();
this.schema = schema; this.schema = schema;
this.contentsState.load();
this.updateQueries(); this.updateQueries();
this.updateModel();
this.updateTable(); this.updateTable();
}));
this.own( this.contentsState.loadAndListen(this.contentsRoute);
this.contentsState.statuses
.subscribe(() => {
this.updateModel();
})); }));
this.own( this.own(
@ -89,8 +99,6 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit {
this.languages = languages.map(x => x.language); this.languages = languages.map(x => x.language);
this.language = this.languages.find(x => x.isMaster)!; this.language = this.languages.find(x => x.isMaster)!;
this.languageMaster = this.language; this.languageMaster = this.language;
this.updateModel();
})); }));
} }
@ -138,14 +146,13 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit {
this.contentsState.search(query); this.contentsState.search(query);
} }
public isItemSelected(content: ContentDto): boolean {
return this.selectedItems[content.id] === true;
}
private selectItems(predicate?: (content: ContentDto) => boolean) { private selectItems(predicate?: (content: ContentDto) => boolean) {
return this.contentsState.snapshot.contents.filter(c => this.selectedItems[c.id] && (!predicate || predicate(c))); return this.contentsState.snapshot.contents.filter(c => this.selectedItems[c.id] && (!predicate || predicate(c)));
} }
public isItemSelected(content: ContentDto): boolean {
return this.selectedItems[content.id] === true;
}
public selectLanguage(language: AppLanguageDto) { public selectLanguage(language: AppLanguageDto) {
this.language = language; this.language = language;
} }
@ -166,7 +173,7 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit {
this.selectedItems = {}; this.selectedItems = {};
if (isSelected) { if (isSelected) {
for (let content of this.contentsState.snapshot.contents) { for (const content of this.contentsState.snapshot.contents) {
this.selectedItems[content.id] = true; this.selectedItems[content.id] = true;
} }
} }
@ -174,7 +181,7 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit {
this.updateSelectionSummary(); this.updateSelectionSummary();
} }
public trackByContent(index: number, content: ContentDto): string { public trackByContent(content: ContentDto): string {
return content.id; return content.id;
} }
@ -184,13 +191,13 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit {
this.selectionCanDelete = true; this.selectionCanDelete = true;
this.nextStatuses = {}; this.nextStatuses = {};
for (let content of this.contentsState.snapshot.contents) { for (const content of this.contentsState.snapshot.contents) {
for (const info of content.statusUpdates) { for (const info of content.statusUpdates) {
this.nextStatuses[info.status] = info.color; this.nextStatuses[info.status] = info.color;
} }
} }
for (let content of this.contentsState.snapshot.contents) { for (const content of this.contentsState.snapshot.contents) {
if (this.selectedItems[content.id]) { if (this.selectedItems[content.id]) {
this.selectionCount++; this.selectionCount++;
@ -220,10 +227,4 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit {
this.tableView = new TableFields(this.uiState, this.schema); this.tableView = new TableFields(this.uiState, this.schema);
} }
} }
private updateModel() {
if (this.schema && this.languages) {
this.queryModel = queryModelFromSchema(this.schema, this.languages, this.contentsState.snapshot.statuses);
}
}
} }

2
frontend/app/features/content/shared/forms/array-editor.component.ts

@ -70,7 +70,7 @@ export class ArrayEditorComponent {
} }
public move(control: AbstractControl, index: number) { public move(control: AbstractControl, index: number) {
let controls = [...this.arrayControl.controls]; const controls = [...this.arrayControl.controls];
controls.splice(controls.indexOf(control), 1); controls.splice(controls.indexOf(control), 1);
controls.splice(index, 0, control); controls.splice(index, 0, control);

4
frontend/app/features/content/shared/forms/array-item.component.ts

@ -108,7 +108,7 @@ export class ArrayItemComponent implements OnChanges, OnDestroy {
private updateFields() { private updateFields() {
const fields: FieldControl[] = []; const fields: FieldControl[] = [];
for (let field of this.field.nested) { for (const field of this.field.nested) {
const control = this.itemForm.get(field.name)!; const control = this.itemForm.get(field.name)!;
if (control || this.field.properties.isContentField) { if (control || this.field.properties.isContentField) {
@ -122,7 +122,7 @@ export class ArrayItemComponent implements OnChanges, OnDestroy {
private updateTitle() { private updateTitle() {
const values: string[] = []; const values: string[] = [];
for (let { control, field } of this.fieldControls) { for (const { control, field } of this.fieldControls) {
const formatted = FieldFormatter.format(field, control.value); const formatted = FieldFormatter.format(field, control.value);
if (formatted) { if (formatted) {

2
frontend/app/features/content/shared/list/content-list-cell.directive.ts

@ -11,7 +11,7 @@ import { MetaFields, RootFieldDto, TableField, Types } from '@app/shared';
export function getTableWidth(fields: ReadonlyArray<TableField>) { export function getTableWidth(fields: ReadonlyArray<TableField>) {
let result = 0; let result = 0;
for (let field of fields) { for (const field of fields) {
result += getCellWidth(field); result += getCellWidth(field);
} }

23
frontend/app/features/rules/pages/events/rule-events-page.component.ts

@ -6,32 +6,27 @@
*/ */
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router'; import { Router2State, RuleEventDto, RuleEventsState } from '@app/shared';
import { ResourceOwner, RuleEventDto, RuleEventsState } from '@app/shared';
@Component({ @Component({
selector: 'sqx-rule-events-page', selector: 'sqx-rule-events-page',
styleUrls: ['./rule-events-page.component.scss'], styleUrls: ['./rule-events-page.component.scss'],
templateUrl: './rule-events-page.component.html' templateUrl: './rule-events-page.component.html',
providers: [
Router2State
]
}) })
export class RuleEventsPageComponent extends ResourceOwner implements OnInit { export class RuleEventsPageComponent implements OnInit {
public selectedEventId: string | null = null; public selectedEventId: string | null = null;
constructor( constructor(
public readonly ruleEventsState: RuleEventsState, public readonly ruleEventsRoute: Router2State,
private readonly route: ActivatedRoute public readonly ruleEventsState: RuleEventsState
) { ) {
super();
} }
public ngOnInit() { public ngOnInit() {
this.own( this.ruleEventsState.loadAndListen(this.ruleEventsRoute);
this.route.queryParams
.subscribe(x => {
this.ruleEventsState.filterByRule(x.ruleId);
}));
this.ruleEventsState.load();
} }
public reload() { public reload() {

2
frontend/app/features/settings/pages/contributors/contributors-page.component.html

@ -49,7 +49,7 @@
<table class="table table-items table-fixed"> <table class="table table-items table-fixed">
<tbody *ngFor="let contributor of contributors; trackBy: trackByContributor" <tbody *ngFor="let contributor of contributors; trackBy: trackByContributor"
[sqxContributor]="contributor" [sqxContributor]="contributor"
[search]="contributorsState.snapshot.queryRegex" [search]="contributorsState.queryRegex | async"
[roles]="roles"> [roles]="roles">
</tbody> </tbody>
</table> </table>

10
frontend/app/features/settings/pages/contributors/contributors-page.component.ts

@ -6,17 +6,21 @@
*/ */
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { ContributorDto, ContributorsState, DialogModel, RolesState } from '@app/shared'; import { ContributorDto, ContributorsState, DialogModel, RolesState, Router2State } from '@app/shared';
@Component({ @Component({
selector: 'sqx-contributors-page', selector: 'sqx-contributors-page',
styleUrls: ['./contributors-page.component.scss'], styleUrls: ['./contributors-page.component.scss'],
templateUrl: './contributors-page.component.html' templateUrl: './contributors-page.component.html',
providers: [
Router2State
]
}) })
export class ContributorsPageComponent implements OnInit { export class ContributorsPageComponent implements OnInit {
public importDialog = new DialogModel(); public importDialog = new DialogModel();
constructor( constructor(
public readonly contributorsRoute: Router2State,
public readonly contributorsState: ContributorsState, public readonly contributorsState: ContributorsState,
public readonly rolesState: RolesState public readonly rolesState: RolesState
) { ) {
@ -25,7 +29,7 @@ export class ContributorsPageComponent implements OnInit {
public ngOnInit() { public ngOnInit() {
this.rolesState.load(); this.rolesState.load();
this.contributorsState.load(); this.contributorsState.loadAndListen(this.contributorsRoute);
} }
public reload() { public reload() {

2
frontend/app/framework/angular/forms/editors/checkbox-group.component.ts

@ -105,7 +105,7 @@ export class CheckboxGroupComponent extends StatefulControlComponent<State, stri
let width = 0; let width = 0;
for (let value of this.valuesSorted) { for (const value of this.valuesSorted) {
width += 30; width += 30;
width += ctx.measureText(value.name).width; width += ctx.measureText(value.name).width;
} }

4
frontend/app/framework/angular/forms/editors/toggle.component.ts

@ -47,12 +47,14 @@ export class ToggleComponent extends StatefulControlComponent<State, boolean | n
} }
public changeState(event: MouseEvent) { public changeState(event: MouseEvent) {
let { isDisabled, isChecked } = this.snapshot; const isDisabled = this.snapshot.isDisabled;
if (isDisabled) { if (isDisabled) {
return; return;
} }
let isChecked = this.snapshot.isChecked;
if (this.threeStates && (event.ctrlKey || event.shiftKey)) { if (this.threeStates && (event.ctrlKey || event.shiftKey)) {
if (isChecked) { if (isChecked) {
isChecked = null; isChecked = null;

2
frontend/app/framework/angular/modals/modal-placement.directive.ts

@ -67,7 +67,7 @@ export class ModalPlacementDirective extends ResourceOwner implements AfterViewI
this.renderer.setStyle(modalRef, 'bottom', 'auto'); this.renderer.setStyle(modalRef, 'bottom', 'auto');
this.renderer.setStyle(modalRef, 'right', 'auto'); this.renderer.setStyle(modalRef, 'right', 'auto');
let zIndex = window.document.defaultView!.getComputedStyle(modalRef).getPropertyValue('z-index'); const zIndex = window.document.defaultView!.getComputedStyle(modalRef).getPropertyValue('z-index');
if (!zIndex || zIndex === 'auto') { if (!zIndex || zIndex === 'auto') {
this.renderer.setStyle(modalRef, 'z-index', 10000); this.renderer.setStyle(modalRef, 'z-index', 10000);

2
frontend/app/framework/angular/pipes/colors.pipes.ts

@ -97,7 +97,7 @@ function rgbToHsv({ r, g, b }: RGBColor): HSVColor {
function hsvToRgb({ h, s, v }: HSVColor): RGBColor { function hsvToRgb({ h, s, v }: HSVColor): RGBColor {
let r = 0, g = 0, b = 0; let r = 0, g = 0, b = 0;
let i = Math.floor(h * 6); const i = Math.floor(h * 6);
const f = h * 6 - i; const f = h * 6 - i;
const p = v * (1 - s); const p = v * (1 - s);
const q = v * (1 - f * s); const q = v * (1 - f * s);

8
frontend/app/framework/angular/pipes/numbers.pipes.ts

@ -41,11 +41,11 @@ export class FileSizePipe implements PipeTransform {
} }
} }
export function calculateFileSize(value: number) { export function calculateFileSize(value: number, factor = 1024) {
let u = 0, s = 1024; let u = 0;
while (value >= s || -value >= s) { while (value >= factor || -value >= factor) {
value /= s; value /= factor;
u++; u++;
} }

337
frontend/app/framework/angular/routers/router-2-state.spec.ts

@ -0,0 +1,337 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { NavigationExtras, Params, Router } from '@angular/router';
import { LocalStoreService, MathHelper, Pager } from '@app/shared';
import { BehaviorSubject } from 'rxjs';
import { IMock, It, Mock, Times } from 'typemoq';
import { State } from './../../state';
import { PagerSynchronizer, Router2State, StringKeysSynchronizer, StringSynchronizer } from './router-2-state';
describe('Router2State', () => {
describe('Strings', () => {
const synchronizer = new StringSynchronizer('key');
it('should write string to route', () => {
const params: Params = {};
const value = 'my-string';
synchronizer.writeValue(value, params);
expect(params['key']).toEqual('my-string');
});
it('should not write value to route when not a string', () => {
const params: Params = {};
const value = 123;
synchronizer.writeValue(value, params);
expect(params).toEqual({});
});
it('should get string from route', () => {
const params: Params = {
key: 'my-string'
};
const value = synchronizer.getValue(params);
expect(value).toEqual('my-string');
});
});
describe('StringKeys', () => {
const synchronizer = new StringKeysSynchronizer('key');
it('should write object keys to route', () => {
const params: Params = {};
const value = {
flag1: true,
flag2: false
};
synchronizer.writeValue(value, params);
expect(params['key']).toEqual('flag1,flag2');
});
it('should write empty object to route', () => {
const params: Params = {};
const value = {};
synchronizer.writeValue(value, params);
expect(params['key']).toEqual('');
});
it('should not write value to route when not an object', () => {
const params: Params = {};
const value = 123;
synchronizer.writeValue(value, params);
expect(params).toEqual({});
});
it('should get object from route', () => {
const params: Params = { key: 'flag1,flag2' };
const value = synchronizer.getValue(params);
expect(value).toEqual({ flag1: true, flag2: true });
});
it('should get object with empty keys from route', () => {
const params: Params = { key: 'flag1,,,flag2' };
const value = synchronizer.getValue(params);
expect(value).toEqual({ flag1: true, flag2: true });
});
});
describe('Pager', () => {
let synchronizer: PagerSynchronizer;
let localStore: IMock<LocalStoreService>;
beforeEach(() => {
localStore = Mock.ofType<LocalStoreService>();
synchronizer = new PagerSynchronizer(localStore.object, 'contents', 30);
});
it('should write pager to route and local store', () => {
const params: Params = {};
const value = new Pager(0, 10, 20, true);
synchronizer.writeValue(value, params);
expect(params['page']).toEqual('10');
expect(params['take']).toEqual('20');
localStore.verify(x => x.setInt('contents.pageSize', 20), Times.once());
});
it('should not write page if zero', () => {
const params: Params = {};
const value = new Pager(0, 0, 20, true);
synchronizer.writeValue(value, params);
expect(params['page']).toBeUndefined();
expect(params['take']).toEqual('20');
localStore.verify(x => x.setInt('contents.pageSize', 20), Times.once());
});
it('should not write value to route when not pager', () => {
const params: Params = {};
const value = 123;
synchronizer.writeValue(value, params);
expect(params).toEqual({});
localStore.verify(x => x.setInt('contents.pageSize', 20), Times.never());
});
it('should not write value to route when null', () => {
const params: Params = {};
const value = null;
synchronizer.writeValue(value, params);
expect(params).toEqual({});
localStore.verify(x => x.setInt('contents.pageSize', 20), Times.never());
});
it('should get page and size from route', () => {
const params: Params = { page: '10', take: '40' };
const value = synchronizer.getValue(params);
expect(value).toEqual(new Pager(0, 10, 40, true));
});
it('should get page size from local store as fallback', () => {
localStore.setup(x => x.getInt('contents.pageSize', It.isAny()))
.returns(() => 40);
const params: Params = { page: '10' };
const value = synchronizer.getValue(params);
expect(value).toEqual(new Pager(0, 10, 40, true));
});
it('should get page size from default if local store is invalid', () => {
localStore.setup(x => x.getInt('contents.pageSize', It.isAny()))
.returns(() => -5);
const params: Params = { page: '10' };
const value = synchronizer.getValue(params);
expect(value).toEqual(new Pager(0, 10, 30, true));
});
it('should get page size from default as last fallback', () => {
const params: Params = { page: '10' };
const value = synchronizer.getValue(params);
expect(value).toEqual(new Pager(0, 10, 30, true));
});
it('should fix page number if invalid', () => {
const params: Params = { page: '-10' };
const value = synchronizer.getValue(params);
expect(value).toEqual(new Pager(0, 0, 30, true));
});
});
describe('Implementation', () => {
let localStore: IMock<LocalStoreService>;
let routerQueryParams: BehaviorSubject<Params>;
let routeActivated: any;
let router: IMock<Router>;
let router2State: Router2State;
let state: State<any>;
let invoked = 0;
beforeEach(() => {
localStore = Mock.ofType<LocalStoreService>();
router = Mock.ofType<Router>();
state = new State<any>({});
routerQueryParams = new BehaviorSubject<Params>({});
routeActivated = { queryParams: routerQueryParams, id: MathHelper.guid() };
router2State = new Router2State(routeActivated, router.object, localStore.object);
router2State.mapTo(state)
.keep('keep')
.withString('state1', 'key1')
.withString('state2', 'key2')
.whenSynced(() => { invoked++; })
.build();
invoked = 0;
});
afterEach(() => {
router2State.ngOnDestroy();
});
it('should unsubscribe from route and state', () => {
router2State.ngOnDestroy();
expect(state.changes['observers'].length).toBe(0);
expect(routeActivated.queryParams.observers.length).toBe(0);
});
it('Should sync from route', () => {
routerQueryParams.next({
key1: 'hello',
key2: 'squidex'
});
expect(state.snapshot.state1).toEqual('hello');
expect(state.snapshot.state2).toEqual('squidex');
});
it('Should invoke callback after sync from route', () => {
routerQueryParams.next({
key1: 'hello',
key2: 'squidex'
});
expect(invoked).toEqual(1);
});
it('Should not sync again when nothing changed', () => {
routerQueryParams.next({
key1: 'hello',
key2: 'squidex'
});
routerQueryParams.next({
key1: 'hello',
key2: 'squidex'
});
expect(invoked).toEqual(1);
});
it('Should sync again when new query changed', () => {
routerQueryParams.next({
key1: 'hello',
key2: 'squidex'
});
routerQueryParams.next({
key1: 'hello',
key2: 'squidex',
key3: '!'
});
expect(invoked).toEqual(2);
});
it('Should reset other values when synced from route', () => {
state.next({ other: 123 });
routerQueryParams.next({
key1: 'hello',
key2: 'squidex'
});
expect(state.snapshot.other).toBeUndefined();
});
it('Should keep configued values when synced from route', () => {
state.next({ keep: 123 });
routerQueryParams.next({
key1: 'hello',
key2: 'squidex'
});
expect(state.snapshot.keep).toBe(123);
});
it('Should sync from state', () => {
let routeExtras: NavigationExtras;
router.setup(x => x.navigate([], It.isAny()))
.callback((_, extras) => { routeExtras = extras; });
state.next({
state1: 'hello',
state2: 'squidex'
});
expect(routeExtras!.relativeTo).toBeDefined();
expect(routeExtras!.replaceUrl).toBeTrue();
expect(routeExtras!.queryParamsHandling).toBe('merge');
expect(routeExtras!.queryParams).toEqual({ key1: 'hello', key2: 'squidex' });
});
});
});

299
frontend/app/framework/angular/routers/router-2-state.ts

@ -0,0 +1,299 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
// tslint:disable: readonly-array
import { Injectable, OnDestroy } from '@angular/core';
import { ActivatedRoute, Params, Router } from '@angular/router';
import { LocalStoreService, Pager, Types } from '@app/framework/internal';
import { State } from '@app/framework/state';
import { Subscription } from 'rxjs';
export interface RouteSynchronizer {
getValue(params: Params): any;
writeValue(state: any, params: Params): void;
}
export class PagerSynchronizer implements RouteSynchronizer {
constructor(
private readonly localStore: LocalStoreService,
private readonly storeName: string,
private readonly defaultSize: number
) {
}
public getValue(params: Params) {
let pageSize = 0;
const pageSizeValue = params['take'];
if (Types.isString(pageSizeValue)) {
pageSize = parseInt(pageSizeValue, 10);
}
if (pageSize <= 0 || pageSize > 100 || isNaN(pageSize)) {
pageSize = this.localStore.getInt(`${this.storeName}.pageSize`, this.defaultSize);
}
if (pageSize <= 0 || pageSize > 100 || isNaN(pageSize)) {
pageSize = this.defaultSize;
}
let page = parseInt(params['page'], 10);
if (page <= 0 || isNaN(page)) {
page = 0;
}
return new Pager(0, page, pageSize, true);
}
public writeValue(state: any, params: Params) {
if (Types.is(state, Pager)) {
if (state.page > 0) {
params['page'] = state.page.toString();
}
if (state.pageSize > 0) {
params['take'] = state.pageSize.toString();
this.localStore.setInt(`${this.storeName}.pageSize`, state.pageSize);
}
}
}
}
export class StringSynchronizer implements RouteSynchronizer {
constructor(
private readonly name: string
) {
}
public getValue(params: Params) {
const value = params[this.name];
return value;
}
public writeValue(state: any, params: Params) {
if (Types.isString(state)) {
params[this.name] = state;
}
}
}
export class StringKeysSynchronizer implements RouteSynchronizer {
constructor(
private readonly name: string
) {
}
public getValue(params: Params) {
const value = params[this.name];
const result: { [key: string]: boolean } = {};
if (Types.isString(value)) {
for (const item of value.split(',')) {
if (item.length > 0) {
result[item] = true;
}
}
}
return result;
}
public writeValue(state: any, params: Params) {
if (Types.isObject(state)) {
const value = Object.keys(state).join(',');
params[this.name] = value;
}
}
}
export interface StateSynchronizer {
mapTo<T extends object>(state: State<T>): StateSynchronizerMap<T>;
}
export interface StateSynchronizerMap<T> {
keep(key: keyof T & string): this;
withString(key: keyof T & string, urlName: string): this;
withStrings(key: keyof T & string, urlName: string): this;
withPager(key: keyof T & string, storeName: string, defaultSize: number): this;
whenSynced(action: () => void): this;
withSynchronizer(key: keyof T & string, synchronizer: RouteSynchronizer): this;
build(): void;
}
@Injectable()
export class Router2State implements OnDestroy, StateSynchronizer {
private mapper: Router2StateMap<any>;
constructor(
private readonly route: ActivatedRoute,
private readonly router: Router,
private readonly localStore: LocalStoreService
) {
}
public ngOnDestroy() {
this.mapper?.ngOnDestroy();
}
public mapTo<T extends object>(state: State<T>): Router2StateMap<T> {
this.mapper?.ngOnDestroy();
this.mapper = this.mapper || new Router2StateMap<T>(state, this.route, this.router, this.localStore);
return this.mapper;
}
}
export class Router2StateMap<T extends object> implements OnDestroy, StateSynchronizerMap<T> {
private readonly syncs: { [field: string]: { synchronizer: RouteSynchronizer, value: any } } = {};
private readonly keysToKeep: string[] = [];
private syncDone: (() => void)[] = [];
private lastSyncedParams: Params | undefined;
private subscriptionChanges: Subscription;
private subscriptionQueryParams: Subscription;
constructor(
private readonly state: State<T>,
private readonly route: ActivatedRoute,
private readonly router: Router,
private readonly localStore: LocalStoreService
) {
}
public build() {
this.subscriptionQueryParams =
this.route.queryParams
.subscribe(q => this.syncFromRoute(q));
this.subscriptionChanges =
this.state.changes
.subscribe(s => this.syncToRoute(s));
}
public ngOnDestroy() {
this.syncDone = [];
this.subscriptionQueryParams?.unsubscribe();
this.subscriptionChanges?.unsubscribe();
}
private syncToRoute(state: T) {
let isChanged = false;
for (const key in this.syncs) {
if (this.syncs.hasOwnProperty(key)) {
const target = this.syncs[key];
const value = state[key];
if (value !== target.value) {
target.value = value;
isChanged = true;
}
}
}
if (!isChanged) {
return;
}
const queryParams: Params = {};
for (const key in this.syncs) {
if (this.syncs.hasOwnProperty(key)) {
const { synchronizer, value } = this.syncs[key];
synchronizer.writeValue(value, queryParams);
}
}
this.lastSyncedParams = queryParams;
this.router.navigate([], {
relativeTo: this.route,
queryParams,
queryParamsHandling: 'merge',
replaceUrl: true
});
}
private syncFromRoute(query: Params) {
if (Types.equals(this.lastSyncedParams, query)) {
return;
}
const update: Partial<T> = {};
for (const key in this.syncs) {
if (this.syncs.hasOwnProperty(key)) {
const target = this.syncs[key];
const value = target.synchronizer.getValue(query);
if (value) {
update[key] = value;
}
}
}
for (const key of this.keysToKeep) {
update[key] = this.state.snapshot[key];
}
this.state.resetState(update);
for (const action of this.syncDone) {
action();
}
}
public keep(key: keyof T & string) {
this.keysToKeep.push(key);
return this;
}
public withString(key: keyof T & string, urlName: string) {
return this.withSynchronizer(key, new StringSynchronizer(urlName));
}
public withStrings(key: keyof T & string, urlName: string) {
return this.withSynchronizer(key, new StringKeysSynchronizer(urlName));
}
public withPager(key: keyof T & string, storeName: string, defaultSize = 10) {
return this.withSynchronizer(key, new PagerSynchronizer(this.localStore, storeName, defaultSize));
}
public whenSynced(action: () => void) {
this.syncDone.push(action);
return this;
}
public withSynchronizer(key: keyof T & string, synchronizer: RouteSynchronizer) {
const previous = this.syncs[key];
this.syncs[key] = { synchronizer, value: previous?.value };
return this;
}
}

2
frontend/app/framework/angular/shortcut.component.spec.ts

@ -10,7 +10,7 @@ import { ShortcutService } from '@app/framework/internal';
import { ShortcutComponent } from './shortcut.component'; import { ShortcutComponent } from './shortcut.component';
describe('ShortcutComponent', () => { describe('ShortcutComponent', () => {
let changeDetector: any = { const changeDetector: any = {
detach: () => { detach: () => {
return 0; return 0;
} }

1
frontend/app/framework/declarations.ts

@ -62,6 +62,7 @@ export * from './angular/popup-link.directive';
export * from './angular/resized.directive'; export * from './angular/resized.directive';
export * from './angular/routers/can-deactivate.guard'; export * from './angular/routers/can-deactivate.guard';
export * from './angular/routers/parent-link.directive'; export * from './angular/routers/parent-link.directive';
export * from './angular/routers/router-2-state';
export * from './angular/safe-html.pipe'; export * from './angular/safe-html.pipe';
export * from './angular/scroll-active.directive'; export * from './angular/scroll-active.directive';
export * from './angular/shortcut.component'; export * from './angular/shortcut.component';

2
frontend/app/framework/services/loading.service.spec.ts

@ -10,7 +10,7 @@ import { Subject } from 'rxjs';
import { LoadingService, LoadingServiceFactory } from './loading.service'; import { LoadingService, LoadingServiceFactory } from './loading.service';
describe('LoadingService', () => { describe('LoadingService', () => {
let events = new Subject<Event>(); const events = new Subject<Event>();
it('should instantiate from factory', () => { it('should instantiate from factory', () => {
const loadingService = LoadingServiceFactory(<any>{ events }); const loadingService = LoadingServiceFactory(<any>{ events });

2
frontend/app/framework/state.ts

@ -132,7 +132,7 @@ export class Model<T> {
for (const key in values) { for (const key in values) {
if (values.hasOwnProperty(key)) { if (values.hasOwnProperty(key)) {
let value = values[key]; const value = values[key];
if (value || !validOnly) { if (value || !validOnly) {
clone[key] = value; clone[key] = value;

2
frontend/app/framework/utils/modal-positioner.spec.ts

@ -36,7 +36,7 @@ describe('position', () => {
{ position: 'right-bottom', x: 310, y: 270 } { position: 'right-bottom', x: 310, y: 270 }
]; ];
for (let test of tests) { for (const test of tests) {
const modalRect = buildRect(0, 0, 30, 30); const modalRect = buildRect(0, 0, 30, 30);
it(`should calculate modal position for ${test.position}`, () => { it(`should calculate modal position for ${test.position}`, () => {

25
frontend/app/framework/utils/pager.spec.ts

@ -5,8 +5,6 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { Mock, Times } from 'typemoq';
import { LocalStoreService } from './../services/local-store.service';
import { Pager } from './pager'; import { Pager } from './pager';
describe('Pager', () => { describe('Pager', () => {
@ -213,27 +211,4 @@ describe('Pager', () => {
canGoPrev: false canGoPrev: false
}); });
}); });
it('should create pager from local store', () => {
const localStore = Mock.ofType<LocalStoreService>();
localStore.setup(x => x.getInt('my.pageSize', 15))
.returns((() => 25));
const pager = Pager.fromLocalStore('my', localStore.object, 15);
expect(pager.pageSize).toBe(25);
});
it('should save pager to local store', () => {
const localStore = Mock.ofType<LocalStoreService>();
const pager = new Pager(0, 0, 25);
pager.saveTo('my', localStore.object);
localStore.verify(x => x.setInt('my.pageSize', 25), Times.once());
expect().nothing();
});
}); });

21
frontend/app/framework/utils/pager.ts

@ -5,8 +5,6 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { LocalStoreService } from './../services/local-store.service';
export class Pager { export class Pager {
public canGoNext = false; public canGoNext = false;
public canGoPrev = false; public canGoPrev = false;
@ -19,11 +17,12 @@ export class Pager {
constructor( constructor(
public readonly numberOfItems: number, public readonly numberOfItems: number,
public readonly page = 0, public readonly page = 0,
public readonly pageSize = 10 public readonly pageSize = 10,
unsafe = false
) { ) {
const totalPages = Math.ceil(numberOfItems / this.pageSize); const totalPages = Math.ceil(numberOfItems / this.pageSize);
if (this.page >= totalPages && this.page > 0) { if (this.page >= totalPages && this.page > 0 && !unsafe) {
this.page = page = totalPages - 1; this.page = page = totalPages - 1;
} }
@ -36,20 +35,6 @@ export class Pager {
this.skip = page * pageSize; this.skip = page * pageSize;
} }
public static fromLocalStore(name: string, localStore: LocalStoreService, size = 10) {
let pageSize = localStore.getInt(`${name}.pageSize`, size);
if (pageSize < 0 || pageSize > 100) {
pageSize = size;
}
return new Pager(0, 0, pageSize);
}
public saveTo(name: string, localStore: LocalStoreService) {
localStore.setInt(`${name}.pageSize`, this.pageSize);
}
public goNext(): Pager { public goNext(): Pager {
if (!this.canGoNext) { if (!this.canGoNext) {
return this; return this;

2
frontend/app/framework/utils/tag-values.ts

@ -124,7 +124,7 @@ export function getTagValues(values: ReadonlyArray<string | TagValue>) {
} }
const result: TagValue[] = []; const result: TagValue[] = [];
for (let value of values) { for (const value of values) {
if (Types.isString(value)) { if (Types.isString(value)) {
result.push(new TagValue(value, value, value)); result.push(new TagValue(value, value, value));
} else { } else {

4
frontend/app/framework/utils/types.ts

@ -114,7 +114,7 @@ export module Types {
} else if (Types.isObject(lhs)) { } else if (Types.isObject(lhs)) {
const result = {}; const result = {};
for (let key in any) { for (const key in any) {
if (any.hasOwnProperty(key)) { if (any.hasOwnProperty(key)) {
result[key] = clone(lhs[key]); result[key] = clone(lhs[key]);
} }
@ -162,7 +162,7 @@ export module Types {
return false; return false;
} }
for (let key in lhs) { for (const key in lhs) {
if (lhs.hasOwnProperty(key)) { if (lhs.hasOwnProperty(key)) {
if (!equals(lhs[key], rhs[key], lazyString)) { if (!equals(lhs[key], rhs[key], lazyString)) {
return false; return false;

4
frontend/app/shared/components/assets/assets-list.component.html

@ -30,7 +30,7 @@
<div class="folder-container-over"></div> <div class="folder-container-over"></div>
<sqx-asset-folder [assetFolder]="parent" <sqx-asset-folder [assetFolder]="parent"
(navigate)="state.navigate($event)"> (navigate)="state.navigate($event.id)">
</sqx-asset-folder> </sqx-asset-folder>
</div> </div>
@ -46,7 +46,7 @@
cdkDrag cdkDrag
[cdkDragData]="assetFolder" [cdkDragData]="assetFolder"
[cdkDragDisabled]="isDisabled || !assetFolder.canMove" [cdkDragDisabled]="isDisabled || !assetFolder.canMove"
(navigate)="state.navigate($event)" (navigate)="state.navigate($event.id)"
(delete)="deleteAssetFolder($event)"> (delete)="deleteAssetFolder($event)">
</sqx-asset-folder> </sqx-asset-folder>
</div> </div>

2
frontend/app/shared/components/assets/image-focus-point.component.ts

@ -70,7 +70,7 @@ export class ImageFocusPointComponent implements AfterViewInit, OnDestroy, OnCha
this.x = newFocus.x; this.x = newFocus.x;
this.y = newFocus.y; this.y = newFocus.y;
for (let preview of this.previewImages) { for (const preview of this.previewImages) {
preview.setFocus(newFocus); preview.setFocus(newFocus);
} }
} }

4
frontend/app/shared/components/search/search-form.component.ts

@ -57,10 +57,6 @@ export class SearchFormComponent implements OnChanges {
} }
public ngOnChanges(changes: SimpleChanges) { public ngOnChanges(changes: SimpleChanges) {
if (changes['queryModel'] && !changes['query']) {
this.query = {};
}
if (changes['query'] || changes['queries']) { if (changes['query'] || changes['queries']) {
this.updateSaveKey(); this.updateSaveKey();
} }

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

@ -14,8 +14,8 @@ import { MustBeAuthenticatedGuard } from './must-be-authenticated.guard';
describe('MustBeAuthenticatedGuard', () => { describe('MustBeAuthenticatedGuard', () => {
let router: IMock<Router>; let router: IMock<Router>;
let authService: IMock<AuthService>; let authService: IMock<AuthService>;
let uiOptions = new UIOptions({ map: { type: 'OSM' } }); const uiOptions = new UIOptions({ map: { type: 'OSM' } });
let uiOptionsRedirect = new UIOptions({ map: { type: 'OSM' }, redirectToLogin: true }); const uiOptionsRedirect = new UIOptions({ map: { type: 'OSM' }, redirectToLogin: true });
beforeEach(() => { beforeEach(() => {
router = Mock.ofType<Router>(); router = Mock.ofType<Router>();

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

@ -14,8 +14,8 @@ import { MustBeNotAuthenticatedGuard } from './must-be-not-authenticated.guard';
describe('MustBeNotAuthenticatedGuard', () => { describe('MustBeNotAuthenticatedGuard', () => {
let router: IMock<Router>; let router: IMock<Router>;
let authService: IMock<AuthService>; let authService: IMock<AuthService>;
let uiOptions = new UIOptions({ map: { type: 'OSM' } }); const uiOptions = new UIOptions({ map: { type: 'OSM' } });
let uiOptionsRedirect = new UIOptions({ map: { type: 'OSM' }, redirectToLogin: true }); const uiOptionsRedirect = new UIOptions({ map: { type: 'OSM' }, redirectToLogin: true });
beforeEach(() => { beforeEach(() => {
router = Mock.ofType<Router>(); router = Mock.ofType<Router>();

5
frontend/app/shared/services/assets.service.spec.ts

@ -108,6 +108,9 @@ describe('AssetsService', () => {
items: [ items: [
assetFolderResponse(22), assetFolderResponse(22),
assetFolderResponse(23) assetFolderResponse(23)
],
path: [
assetFolderResponse(44)
] ]
}); });
@ -115,6 +118,8 @@ describe('AssetsService', () => {
new AssetFoldersDto(10, [ new AssetFoldersDto(10, [
createAssetFolder(22), createAssetFolder(22),
createAssetFolder(23) createAssetFolder(23)
], [
createAssetFolder(44)
])); ]));
})); }));

16
frontend/app/shared/services/assets.service.ts

@ -85,6 +85,13 @@ export class AssetDto {
} }
export class AssetFoldersDto extends ResultSet<AssetFolderDto> { export class AssetFoldersDto extends ResultSet<AssetFolderDto> {
constructor(total: number, items: ReadonlyArray<AssetFolderDto>,
public readonly path: ReadonlyArray<AssetFolderDto>,
links?: ResourceLinks
) {
super(total, items, links);
}
public get canCreate() { public get canCreate() {
return hasAnyLink(this._links, 'create'); return hasAnyLink(this._links, 'create');
} }
@ -176,7 +183,7 @@ export class AssetsService {
} }
if (tags) { if (tags) {
for (let tag of tags) { for (const tag of tags) {
if (tag && tag.length > 0) { if (tag && tag.length > 0) {
filters.push({ path: 'tags', op: 'eq', value: tag }); filters.push({ path: 'tags', op: 'eq', value: tag });
} }
@ -240,11 +247,12 @@ export class AssetsService {
public getAssetFolders(appName: string, parentId?: string): Observable<AssetFoldersDto> { public getAssetFolders(appName: string, parentId?: string): Observable<AssetFoldersDto> {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/assets/folders?parentId=${parentId}`); const url = this.apiUrl.buildUrl(`api/apps/${appName}/assets/folders?parentId=${parentId}`);
return this.http.get<{ total: number, items: any[], folders: any[] } & Resource>(url).pipe( return this.http.get<{ total: number, items: any[], folders: any[], path: any[] } & Resource>(url).pipe(
map(({ total, items, _links }) => { map(({ total, items, path, _links }) => {
const assetFolders = items.map(item => parseAssetFolder(item)); const assetFolders = items.map(item => parseAssetFolder(item));
const assetPath = path.map(item => parseAssetFolder(item));
return new AssetFoldersDto(total, assetFolders, _links); return new AssetFoldersDto(total, assetFolders, assetPath, _links);
}), }),
pretifyError('Failed to load asset folders. Please reload.')); pretifyError('Failed to load asset folders. Please reload.'));
} }

4
frontend/app/shared/services/autosave.service.ts

@ -51,7 +51,9 @@ export class AutoSaveService {
} }
function getKey(key: AutoSaveKey) { function getKey(key: AutoSaveKey) {
let { contentId, schemaId, schemaVersion } = key; const { schemaId, schemaVersion } = key;
let contentId = key.contentId;
if (!contentId) { if (!contentId) {
contentId = ''; contentId = '';

2
frontend/app/shared/services/contents.service.ts

@ -142,7 +142,7 @@ export class ContentsService {
} }
} }
let fullQuery = [...queryParts, ...queryOdataParts].join('&'); const fullQuery = [...queryParts, ...queryOdataParts].join('&');
if (fullQuery.length > (maxLength || 2000)) { if (fullQuery.length > (maxLength || 2000)) {
const body: any = {}; const body: any = {};

2
frontend/app/shared/services/rules.service.ts

@ -218,7 +218,7 @@ export class RulesService {
const actions: { [name: string]: RuleElementDto } = {}; const actions: { [name: string]: RuleElementDto } = {};
for (let key of Object.keys(items).sort()) { for (const key of Object.keys(items).sort()) {
const value = items[key]; const value = items[key];
const properties = value.properties.map((property: any) => const properties = value.properties.map((property: any) =>

4
frontend/app/shared/services/schemas.service.ts

@ -208,9 +208,9 @@ export class SchemaDetailsDto extends SchemaDto {
} }
function findFields(names: ReadonlyArray<string>, fields: ReadonlyArray<RootFieldDto>): TableField[] { function findFields(names: ReadonlyArray<string>, fields: ReadonlyArray<RootFieldDto>): TableField[] {
let result: TableField[] = []; const result: TableField[] = [];
for (let name of names) { for (const name of names) {
if (name.startsWith('meta.')) { if (name.startsWith('meta.')) {
result.push(name); result.push(name);
} else { } else {

2
frontend/app/shared/services/usages.service.ts

@ -84,7 +84,7 @@ export class UsagesService {
map(body => { map(body => {
const details: { [category: string]: CallsUsagePerDateDto[] } = {}; const details: { [category: string]: CallsUsagePerDateDto[] } = {};
for (let category of Object.keys(body.details)) { for (const category of Object.keys(body.details)) {
details[category] = body.details[category].map((item: any) => details[category] = body.details[category].map((item: any) =>
new CallsUsagePerDateDto( new CallsUsagePerDateDto(
DateTime.parseISO(item.date), DateTime.parseISO(item.date),

10
frontend/app/shared/services/workflows.service.ts

@ -142,7 +142,7 @@ export class WorkflowDto extends Model<WorkflowDto> {
const transitions = this.transitions.map(transition => { const transitions = this.transitions.map(transition => {
if (transition.from === name || transition.to === name) { if (transition.from === name || transition.to === name) {
let newTransition = { ...transition }; const newTransition = { ...transition };
if (newTransition.from === name) { if (newTransition.from === name) {
newTransition.from = newName; newTransition.from = newName;
@ -178,12 +178,12 @@ export class WorkflowDto extends Model<WorkflowDto> {
public serialize(): any { public serialize(): any {
const result = { steps: {}, schemaIds: this.schemaIds, initial: this.initial, name: this.name }; const result = { steps: {}, schemaIds: this.schemaIds, initial: this.initial, name: this.name };
for (let step of this.steps) { for (const step of this.steps) {
const { name, ...values } = step; const { name, ...values } = step;
const s = { ...values, transitions: {} }; const s = { ...values, transitions: {} };
for (let transition of this.getTransitions(step)) { for (const transition of this.getTransitions(step)) {
const { from, to, step: _, ...t } = transition; const { from, to, step: _, ...t } = transition;
s.transitions[to] = t; s.transitions[to] = t;
@ -288,13 +288,13 @@ function parseWorkflow(workflow: any) {
const steps: WorkflowStep[] = []; const steps: WorkflowStep[] = [];
const transitions: WorkflowTransition[] = []; const transitions: WorkflowTransition[] = [];
for (let stepName in workflow.steps) { for (const stepName in workflow.steps) {
if (workflow.steps.hasOwnProperty(stepName)) { if (workflow.steps.hasOwnProperty(stepName)) {
const { transitions: srcTransitions, ...step } = workflow.steps[stepName]; const { transitions: srcTransitions, ...step } = workflow.steps[stepName];
steps.push({ name: stepName, isLocked: stepName === 'Published', ...step }); steps.push({ name: stepName, isLocked: stepName === 'Published', ...step });
for (let to in srcTransitions) { for (const to in srcTransitions) {
if (srcTransitions.hasOwnProperty(to)) { if (srcTransitions.hasOwnProperty(to)) {
const transition = srcTransitions[to]; const transition = srcTransitions[to];

57
frontend/app/shared/state/_test-helpers.ts

@ -5,7 +5,8 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { of } from 'rxjs'; import { StateSynchronizer, StateSynchronizerMap } from '@app/framework';
import { of, Subject } from 'rxjs';
import { Mock } from 'typemoq'; import { Mock } from 'typemoq';
import { AppsState, AuthService, DateTime, Version } from './../'; import { AppsState, AuthService, DateTime, Version } from './../';
@ -33,10 +34,64 @@ const authService = Mock.ofType<AuthService>();
authService.setup(x => x.user) authService.setup(x => x.user)
.returns(() => <any>{ id: modifier, token: modifier }); .returns(() => <any>{ id: modifier, token: modifier });
class DummySynchronizer implements StateSynchronizer, StateSynchronizerMap<any> {
constructor(
private readonly subject: Subject<any>
) {
}
public build() {
return;
}
public mapTo<T extends object>(): StateSynchronizerMap<T> {
return this;
}
public keep() {
return this;
}
public withString() {
return this;
}
public withStrings() {
return this;
}
public withPager() {
return this;
}
public withSynchronizer() {
return this;
}
public whenSynced(action: () => void) {
this.subject.subscribe(() => action());
return this;
}
}
function buildDummyStateSynchronizer(): { synchronizer: StateSynchronizer, trigger: () => void } {
const subject = new Subject<any>();
const synchronizer = new DummySynchronizer(subject);
const trigger = () => {
subject.next();
};
return { synchronizer, trigger };
}
export const TestValues = { export const TestValues = {
app, app,
appsState, appsState,
authService, authService,
buildDummyStateSynchronizer,
creation, creation,
creator, creator,
modified, modified,

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

@ -161,7 +161,7 @@ describe('AssetUploaderState', () => {
it('should update status when uploading asset completes', () => { it('should update status when uploading asset completes', () => {
const file: File = <any>{ name: 'my-file' }; const file: File = <any>{ name: 'my-file' };
let 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();

2
frontend/app/shared/state/assets.forms.ts

@ -62,7 +62,7 @@ export class AnnotateAssetForm extends Form<FormGroup, AnnotateAssetDto, AssetDt
public transformSubmit(value: any) { public transformSubmit(value: any) {
const result = { ...value, metadata: {} }; const result = { ...value, metadata: {} };
for (let item of value.metadata) { for (const item of value.metadata) {
const raw = item.value; const raw = item.value;
let parsed = raw; let parsed = raw;

70
frontend/app/shared/state/assets.state.spec.ts

@ -5,7 +5,7 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { AssetFoldersDto, AssetPathItem, AssetsDto, AssetsService, AssetsState, DialogService, LocalStoreService, MathHelper, Pager, versioned } from '@app/shared/internal'; import { AssetFoldersDto, AssetsDto, AssetsService, AssetsState, DialogService, MathHelper, Pager, versioned } from '@app/shared/internal';
import { of, throwError } from 'rxjs'; import { 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';
@ -16,6 +16,7 @@ describe('AssetsState', () => {
const { const {
app, app,
appsState, appsState,
buildDummyStateSynchronizer,
newVersion newVersion
} = TestValues; } = TestValues;
@ -30,20 +31,15 @@ describe('AssetsState', () => {
let dialogs: IMock<DialogService>; let dialogs: IMock<DialogService>;
let assetsService: IMock<AssetsService>; let assetsService: IMock<AssetsService>;
let assetsState: AssetsState; let assetsState: AssetsState;
let localStore: IMock<LocalStoreService>;
beforeEach(() => { beforeEach(() => {
dialogs = Mock.ofType<DialogService>(); dialogs = Mock.ofType<DialogService>();
localStore = Mock.ofType<LocalStoreService>();
localStore.setup(x => x.getInt('assets.pageSize', 30))
.returns(() => 30);
assetsService = Mock.ofType<AssetsService>(); assetsService = Mock.ofType<AssetsService>();
assetsService.setup(x => x.getTags(app)) assetsService.setup(x => x.getTags(app))
.returns(() => of({ tag1: 1, shared: 2, tag2: 1 })); .returns(() => of({ tag1: 1, shared: 2, tag2: 1 }));
assetsState = new AssetsState(appsState.object, assetsService.object, dialogs.object, localStore.object); assetsState = new AssetsState(appsState.object, assetsService.object, dialogs.object);
}); });
afterEach(() => { afterEach(() => {
@ -53,7 +49,7 @@ describe('AssetsState', () => {
describe('Loading', () => { describe('Loading', () => {
beforeEach(() => { beforeEach(() => {
assetsService.setup(x => x.getAssetFolders(app, MathHelper.EMPTY_GUID)) assetsService.setup(x => x.getAssetFolders(app, MathHelper.EMPTY_GUID))
.returns(() => of(new AssetFoldersDto(2, [assetFolder1, assetFolder2]))).verifiable(Times.atLeastOnce()); .returns(() => of(new AssetFoldersDto(2, [assetFolder1, assetFolder2], []))).verifiable(Times.atLeastOnce());
}); });
it('should load assets', () => { it('should load assets', () => {
@ -112,58 +108,32 @@ describe('AssetsState', () => {
expect().nothing(); expect().nothing();
}); });
it('should update page size in local store', () => { it('should load when synchronizer triggered', () => {
assetsService.setup(x => x.getAssets(app, { take: 50, skip: 0, parentId: MathHelper.EMPTY_GUID })) const { synchronizer, trigger } = buildDummyStateSynchronizer();
.returns(() => of(new AssetsDto(200, []))).verifiable();
assetsService.setup(x => x.getAssets(app, { take: 30, skip: 0, parentId: MathHelper.EMPTY_GUID }))
.returns(() => of(new AssetsDto(200, [asset1, asset2]))).verifiable(Times.exactly(2));
assetsState.setPager(new Pager(0, 0, 50)); assetsState.loadAndListen(synchronizer);
localStore.verify(x => x.setInt('assets.pageSize', 50), Times.atLeastOnce()); trigger();
trigger();
expect().nothing(); expect().nothing();
}); });
}); });
describe('Navigating', () => { describe('Navigating', () => {
beforeEach(() => { it('should load with parent id', () => {
assetsService.setup(x => x.getAssets(app, It.isAny())) assetsService.setup(x => x.getAssetFolders(app, '123'))
.returns(() => of(new AssetsDto(0, []))); .returns(() => of(new AssetFoldersDto(2, [assetFolder1, assetFolder2], []))).verifiable();
assetsService.setup(x => x.getAssetFolders(app, It.isAny()))
.returns(() => of(new AssetFoldersDto(0, [])));
});
it('should move to child', () => {
assetsState.navigate({ id: '1', folderName: 'Folder1' }).subscribe();
assetsState.navigate({ id: '2', folderName: 'Folder2' }).subscribe();
let path: ReadonlyArray<AssetPathItem>; assetsService.setup(x => x.getAssets(app, { take: 30, skip: 0, parentId: '123' }))
.returns(() => of(new AssetsDto(200, []))).verifiable();
assetsState.path.subscribe(result => {
path = result;
});
expect(path!).toEqual([
{ id: MathHelper.EMPTY_GUID, folderName: 'Assets' },
{ id: '1', folderName: 'Folder1' },
{ id: '2', folderName: 'Folder2' }
]);
});
it('should navigate back to parent', () => {
assetsState.navigate({ id: '1', folderName: 'Folder1' }).subscribe();
assetsState.navigate({ id: '2', folderName: 'Folder2' }).subscribe();
assetsState.navigate({ id: MathHelper.EMPTY_GUID, folderName: 'Assets' }).subscribe();
let path: ReadonlyArray<AssetPathItem>;
assetsState.path.subscribe(result => { assetsState.navigate('123').subscribe();
path = result;
});
expect(path!).toEqual([ expect().nothing();
{ id: MathHelper.EMPTY_GUID, folderName: 'Assets' }
]);
}); });
}); });
@ -201,7 +171,7 @@ describe('AssetsState', () => {
describe('Updates', () => { describe('Updates', () => {
beforeEach(() => { beforeEach(() => {
assetsService.setup(x => x.getAssetFolders(app, MathHelper.EMPTY_GUID)) assetsService.setup(x => x.getAssetFolders(app, MathHelper.EMPTY_GUID))
.returns(() => of(new AssetFoldersDto(2, [assetFolder1, assetFolder2]))); .returns(() => of(new AssetFoldersDto(2, [assetFolder1, assetFolder2], [])));
assetsService.setup(x => x.getAssets(app, { take: 30, skip: 0, parentId: MathHelper.EMPTY_GUID })) assetsService.setup(x => x.getAssets(app, { take: 30, skip: 0, parentId: MathHelper.EMPTY_GUID }))
.returns(() => of(new AssetsDto(200, [asset1, asset2]))).verifiable(); .returns(() => of(new AssetsDto(200, [asset1, asset2]))).verifiable();

116
frontend/app/shared/state/assets.state.ts

@ -6,12 +6,12 @@
*/ */
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { compareStrings, DialogService, LocalStoreService, MathHelper, Pager, shareSubscribed, State } from '@app/framework'; import { compareStrings, DialogService, MathHelper, Pager, shareSubscribed, State, StateSynchronizer } from '@app/framework';
import { empty, forkJoin, Observable, of, throwError } from 'rxjs'; import { empty, forkJoin, Observable, of, throwError } from 'rxjs';
import { catchError, finalize, tap } from 'rxjs/operators'; import { catchError, finalize, tap } from 'rxjs/operators';
import { AnnotateAssetDto, AssetDto, AssetFolderDto, AssetsService, RenameAssetFolderDto } from './../services/assets.service'; import { AnnotateAssetDto, AssetDto, AssetFolderDto, AssetsService, RenameAssetFolderDto } from './../services/assets.service';
import { AppsState } from './apps.state'; import { AppsState } from './apps.state';
import { Query } from './query'; import { Query, QueryFullTextSynchronizer } from './query';
export type AssetPathItem = { id: string, folderName: string }; export type AssetPathItem = { id: string, folderName: string };
@ -19,9 +19,9 @@ export type TagsAvailable = { [name: string]: number };
export type TagsSelected = { [name: string]: boolean }; export type TagsSelected = { [name: string]: boolean };
export type Tag = { name: string, count: number; }; export type Tag = { name: string, count: number; };
const EMPTY_FOLDERS: { canCreate: boolean, items: ReadonlyArray<AssetFolderDto> } = { canCreate: false, items: [] }; const EMPTY_FOLDERS: { canCreate: boolean, items: ReadonlyArray<AssetFolderDto>, path?: ReadonlyArray<AssetFolderDto> } = { canCreate: false, items: [] };
const ROOT_PATH: ReadonlyArray<AssetPathItem> = [{ id: MathHelper.EMPTY_GUID, folderName: 'Assets' }]; const ROOT_ITEM: AssetPathItem = { id: MathHelper.EMPTY_GUID, folderName: 'Assets' };
interface Snapshot { interface Snapshot {
// All assets tags. // All assets tags.
@ -46,7 +46,10 @@ interface Snapshot {
path: ReadonlyArray<AssetPathItem>; path: ReadonlyArray<AssetPathItem>;
// The parent folder. // The parent folder.
parentFolder?: AssetPathItem; parentId: string;
// Indicates if the assets are loaded once.
isLoadedOnce?: boolean;
// Indicates if the assets are loaded. // Indicates if the assets are loaded.
isLoaded?: boolean; isLoaded?: boolean;
@ -103,10 +106,7 @@ export class AssetsState extends State<Snapshot> {
this.project(x => x.path.length > 0); this.project(x => x.path.length > 0);
public parentFolder = public parentFolder =
this.project(x => x.parentFolder); this.project(x => getParent(x.path));
public pathRoot =
this.project(x => x.path[x.path.length - 1]);
public canCreate = public canCreate =
this.project(x => x.canCreate === true); this.project(x => x.canCreate === true);
@ -117,21 +117,27 @@ export class AssetsState extends State<Snapshot> {
constructor( constructor(
private readonly appsState: AppsState, private readonly appsState: AppsState,
private readonly assetsService: AssetsService, private readonly assetsService: AssetsService,
private readonly dialogs: DialogService, private readonly dialogs: DialogService
private readonly localStore: LocalStoreService
) { ) {
super({ super({
assetFolders: [], assetFolders: [],
assets: [], assets: [],
assetsPager: Pager.fromLocalStore('assets', localStore, 30), assetsPager: new Pager(0, 0, 30),
path: ROOT_PATH, parentId: ROOT_ITEM.id,
path: [ROOT_ITEM],
tagsAvailable: {}, tagsAvailable: {},
tagsSelected: {} tagsSelected: {}
}); });
}
this.assetsPager.subscribe(pager => { public loadAndListen(synchronizer: StateSynchronizer) {
pager.saveTo('assets', this.localStore); synchronizer.mapTo(this)
}); .withPager('assetsPager', 'assets', 20)
.withString('parentId', 'parent')
.withStrings('tagsSelected', 'tags')
.withSynchronizer('assetsQuery', new QueryFullTextSynchronizer())
.whenSynced(() => this.loadInternal(false))
.build();
} }
public load(isReload = false): Observable<any> { public load(isReload = false): Observable<any> {
@ -150,30 +156,32 @@ export class AssetsState extends State<Snapshot> {
skip: this.snapshot.assetsPager.skip skip: this.snapshot.assetsPager.skip
}; };
if (this.parentId) { const withQuery = hasQuery(this.snapshot);
query.parentId = this.parentId;
}
if (this.snapshot.assetsQuery) { if (withQuery) {
query.query = this.snapshot.assetsQuery; if (this.snapshot.assetsQuery) {
} query.query = this.snapshot.assetsQuery;
}
const searchTags = Object.keys(this.snapshot.tagsSelected); const searchTags = Object.keys(this.snapshot.tagsSelected);
if (searchTags.length > 0) { if (searchTags.length > 0) {
query.tags = searchTags; query.tags = searchTags;
}
} else {
query.parentId = this.snapshot.parentId;
} }
const assets$ = const assets$ =
this.assetsService.getAssets(this.appName, query); this.assetsService.getAssets(this.appName, query);
const assetFolders$ = const assetFolders$ =
this.snapshot.path.length === 0 ? !withQuery ?
of(EMPTY_FOLDERS) : this.assetsService.getAssetFolders(this.appName, this.snapshot.parentId) :
this.assetsService.getAssetFolders(this.appName, this.parentId); of(EMPTY_FOLDERS);
const tags$ = const tags$ =
this.snapshot.path.length === 1 ? !withQuery || !this.snapshot.isLoadedOnce ?
this.assetsService.getTags(this.appName) : this.assetsService.getTags(this.appName) :
of(this.snapshot.tagsAvailable); of(this.snapshot.tagsAvailable);
@ -183,6 +191,10 @@ export class AssetsState extends State<Snapshot> {
this.dialogs.notifyInfo('Assets reloaded.'); this.dialogs.notifyInfo('Assets reloaded.');
} }
const path = assetFolders.path ?
[ROOT_ITEM, ...assetFolders.path] :
[];
this.next(s => ({ this.next(s => ({
...s, ...s,
assetFolders: assetFolders.items, assetFolders: assetFolders.items,
@ -191,8 +203,9 @@ export class AssetsState extends State<Snapshot> {
canCreate: assets.canCreate, canCreate: assets.canCreate,
canCreateFolders: assetFolders.canCreate, canCreateFolders: assetFolders.canCreate,
isLoaded: true, isLoaded: true,
isLoadedOnce: true,
isLoading: false, isLoading: false,
parentFolder: getParent(s.path), path,
tagsAvailable tagsAvailable
})); }));
}), }),
@ -203,7 +216,7 @@ export class AssetsState extends State<Snapshot> {
} }
public addAsset(asset: AssetDto) { public addAsset(asset: AssetDto) {
if (asset.parentId !== this.parentId || this.snapshot.assets.find(x => x.id === asset.id)) { if (asset.parentId !== this.snapshot.parentId || this.snapshot.assets.find(x => x.id === asset.id)) {
return; return;
} }
@ -218,9 +231,9 @@ export class AssetsState extends State<Snapshot> {
} }
public createAssetFolder(folderName: string) { public createAssetFolder(folderName: string) {
return this.assetsService.postAssetFolder(this.appName, { folderName, parentId: this.parentId }).pipe( return this.assetsService.postAssetFolder(this.appName, { folderName, parentId: this.snapshot.parentId }).pipe(
tap(assetFolder => { tap(assetFolder => {
if (assetFolder.parentId !== this.parentId) { if (assetFolder.parentId !== this.snapshot.parentId) {
return; return;
} }
@ -334,6 +347,12 @@ export class AssetsState extends State<Snapshot> {
shareSubscribed(this.dialogs)); shareSubscribed(this.dialogs));
} }
public navigate(parentId: string) {
this.next({ parentId });
return this.loadInternal(false);
}
public setPager(assetsPager: Pager) { public setPager(assetsPager: Pager) {
this.next({ assetsPager }); this.next({ assetsPager });
@ -352,13 +371,6 @@ export class AssetsState extends State<Snapshot> {
newState.tagsSelected = tags; newState.tagsSelected = tags;
} }
if (Object.keys(newState.tagsSelected).length > 0 || (newState.assetsQuery && newState.assetsQuery.fullText)) {
newState.path = [];
newState.assetFolders = [];
} else if (newState.path.length === 0) {
newState.path = ROOT_PATH;
}
return newState; return newState;
}); });
@ -387,24 +399,6 @@ export class AssetsState extends State<Snapshot> {
return this.searchInternal(null, tagsSelected); return this.searchInternal(null, tagsSelected);
} }
public navigate(folder: AssetPathItem) {
this.next(s => {
let path = s.path;
const index = path.findIndex(x => x.id === folder.id);
if (index >= 0) {
path = path.slice(0, index + 1);
} else {
path = [...path, folder];
}
return { ...s, path };
});
return this.loadInternal(false);
}
public resetTags(): Observable<any> { public resetTags(): Observable<any> {
return this.searchInternal(null, {}); return this.searchInternal(null, {});
} }
@ -414,7 +408,7 @@ export class AssetsState extends State<Snapshot> {
} }
public get parentId() { public get parentId() {
return this.snapshot.path.length > 0 ? this.snapshot.path[this.snapshot.path.length - 1].id : undefined; return this.snapshot.parentId;
} }
private get appName() { private get appName() {
@ -458,6 +452,10 @@ function sort(tags: { [name: string]: number }) {
return Object.keys(tags).sort(compareStrings).map(name => ({ name, count: tags[name] })); return Object.keys(tags).sort(compareStrings).map(name => ({ name, count: tags[name] }));
} }
function hasQuery(state: Snapshot) {
return (state.assetsQuery && !!state.assetsQuery.fullText) || Object.keys(state.tagsSelected).length > 0;
}
function getParent(path: ReadonlyArray<AssetPathItem>) { function getParent(path: ReadonlyArray<AssetPathItem>) {
return path.length > 1 ? { folderName: '<Parent>', id: path[path.length - 2].id } : undefined; return path.length > 1 ? { folderName: '<Parent>', id: path[path.length - 2].id } : undefined;
} }

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

@ -735,7 +735,7 @@ describe('ContentForm', () => {
const form = parent.get(path); const form = parent.get(path);
if (form) { if (form) {
for (let key in test) { for (const key in test) {
if (test.hasOwnProperty(key)) { if (test.hasOwnProperty(key)) {
const a = form[key]; const a = form[key];
const e = test[key]; const e = test[key];

40
frontend/app/shared/state/contents.state.ts

@ -6,14 +6,14 @@
*/ */
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { DialogService, ErrorDto, LocalStoreService, Pager, shareSubscribed, State, Types, Version, Versioned } from '@app/framework'; import { DialogService, ErrorDto, Pager, shareSubscribed, State, StateSynchronizer, Types, Version, Versioned } from '@app/framework';
import { empty, forkJoin, Observable, of } from 'rxjs'; import { empty, forkJoin, Observable, of } from 'rxjs';
import { catchError, finalize, switchMap, tap } from 'rxjs/operators'; import { catchError, finalize, switchMap, tap } from 'rxjs/operators';
import { ContentDto, ContentsService, StatusInfo } from './../services/contents.service'; import { ContentDto, ContentsService, StatusInfo } from './../services/contents.service';
import { SchemaDto } from './../services/schemas.service'; import { SchemaDto } from './../services/schemas.service';
import { AppsState } from './apps.state'; import { AppsState } from './apps.state';
import { SavedQuery } from './queries'; import { SavedQuery } from './queries';
import { Query } from './query'; import { Query, QuerySynchronizer } from './query';
import { SchemasState } from './schemas.state'; import { SchemasState } from './schemas.state';
interface Snapshot { interface Snapshot {
@ -46,8 +46,6 @@ interface Snapshot {
} }
export abstract class ContentsStateBase extends State<Snapshot> { export abstract class ContentsStateBase extends State<Snapshot> {
private previousId: string;
public selectedContent: Observable<ContentDto | null | undefined> = public selectedContent: Observable<ContentDto | null | undefined> =
this.project(x => x.selectedContent, Types.equals); this.project(x => x.selectedContent, Types.equals);
@ -84,16 +82,11 @@ export abstract class ContentsStateBase extends State<Snapshot> {
constructor( constructor(
private readonly appsState: AppsState, private readonly appsState: AppsState,
private readonly contentsService: ContentsService, private readonly contentsService: ContentsService,
private readonly dialogs: DialogService, private readonly dialogs: DialogService
private readonly localStore: LocalStoreService
) { ) {
super({ super({
contents: [], contents: [],
contentsPager: Pager.fromLocalStore('contents', localStore) contentsPager: new Pager(0)
});
this.contentsPager.subscribe(pager => {
pager.saveTo('contents', this.localStore);
}); });
} }
@ -121,11 +114,18 @@ export abstract class ContentsStateBase extends State<Snapshot> {
})); }));
} }
public load(isReload = false): Observable<any> { public loadAndListen(synchronizer: StateSynchronizer) {
if (!isReload && this.schemaId !== this.previousId) { synchronizer.mapTo(this)
const contentsPager = this.snapshot.contentsPager.reset(); .keep('selectedContent')
.withPager('contentsPager', 'contents', 10)
.withSynchronizer('contentsQuery', new QuerySynchronizer())
.whenSynced(() => this.loadInternal(false))
.build();
}
this.resetState({ contentsPager, selectedContent: this.snapshot.selectedContent }); public load(isReload = false): Observable<any> {
if (!isReload) {
this.resetState({ selectedContent: this.snapshot.selectedContent });
} }
return this.loadInternal(isReload); return this.loadInternal(isReload);
@ -150,8 +150,6 @@ export abstract class ContentsStateBase extends State<Snapshot> {
this.next({ isLoading: true }); this.next({ isLoading: true });
this.previousId = this.schemaId;
const query: any = { const query: any = {
take: this.snapshot.contentsPager.pageSize, take: this.snapshot.contentsPager.pageSize,
skip: this.snapshot.contentsPager.skip skip: this.snapshot.contentsPager.skip
@ -331,10 +329,10 @@ export abstract class ContentsStateBase extends State<Snapshot> {
@Injectable() @Injectable()
export class ContentsState extends ContentsStateBase { export class ContentsState extends ContentsStateBase {
constructor(appsState: AppsState, contentsService: ContentsService, dialogs: DialogService, localStore: LocalStoreService, constructor(appsState: AppsState, contentsService: ContentsService, dialogs: DialogService,
private readonly schemasState: SchemasState private readonly schemasState: SchemasState
) { ) {
super(appsState, contentsService, dialogs, localStore); super(appsState, contentsService, dialogs);
} }
protected get schemaId() { protected get schemaId() {
@ -347,9 +345,9 @@ export class ManualContentsState extends ContentsStateBase {
public schema: SchemaDto; public schema: SchemaDto;
constructor( constructor(
appsState: AppsState, contentsService: ContentsService, dialogs: DialogService, localStore: LocalStoreService appsState: AppsState, contentsService: ContentsService, dialogs: DialogService
) { ) {
super(appsState, contentsService, dialogs, localStore); super(appsState, contentsService, dialogs);
} }
protected get schemaId() { protected get schemaId() {

34
frontend/app/shared/state/contributors.state.spec.ts

@ -6,7 +6,7 @@
*/ */
import { ErrorDto } from '@app/framework'; import { ErrorDto } from '@app/framework';
import { ContributorDto, ContributorsPayload, ContributorsService, ContributorsState, DialogService, LocalStoreService, Pager, versioned } from '@app/shared/internal'; import { ContributorDto, ContributorsPayload, ContributorsService, ContributorsState, DialogService, Pager, versioned } from '@app/shared/internal';
import { empty, of, throwError } from 'rxjs'; import { empty, of, throwError } from 'rxjs';
import { catchError, onErrorResumeNext } from 'rxjs/operators'; import { catchError, onErrorResumeNext } from 'rxjs/operators';
import { IMock, It, Mock, Times } from 'typemoq'; import { IMock, It, Mock, Times } from 'typemoq';
@ -17,11 +17,12 @@ describe('ContributorsState', () => {
const { const {
app, app,
appsState, appsState,
buildDummyStateSynchronizer,
newVersion, newVersion,
version version
} = TestValues; } = TestValues;
let allIds: number[] = []; const allIds: number[] = [];
for (let i = 1; i <= 20; i++) { for (let i = 1; i <= 20; i++) {
allIds.push(i); allIds.push(i);
@ -32,18 +33,15 @@ describe('ContributorsState', () => {
let dialogs: IMock<DialogService>; let dialogs: IMock<DialogService>;
let contributorsService: IMock<ContributorsService>; let contributorsService: IMock<ContributorsService>;
let contributorsState: ContributorsState; let contributorsState: ContributorsState;
let localStore: IMock<LocalStoreService>;
beforeEach(() => { beforeEach(() => {
dialogs = Mock.ofType<DialogService>(); dialogs = Mock.ofType<DialogService>();
localStore = Mock.ofType<LocalStoreService>();
contributorsService = Mock.ofType<ContributorsService>(); contributorsService = Mock.ofType<ContributorsService>();
contributorsService.setup(x => x.getContributors(app)) contributorsService.setup(x => x.getContributors(app))
.returns(() => of(versioned(version, oldContributors))).verifiable(); .returns(() => of(versioned(version, oldContributors))).verifiable(Times.atLeastOnce());
contributorsState = new ContributorsState(appsState.object, contributorsService.object, dialogs.object, localStore.object); contributorsState = new ContributorsState(appsState.object, contributorsService.object, dialogs.object);
}); });
afterEach(() => { afterEach(() => {
@ -100,15 +98,6 @@ describe('ContributorsState', () => {
expect(contributorsState.snapshot.contributorsPager).toEqual(new Pager(20, 1, 10)); expect(contributorsState.snapshot.contributorsPager).toEqual(new Pager(20, 1, 10));
}); });
it('should update page size in local store', () => {
contributorsState.load().subscribe();
contributorsState.setPager(new Pager(0, 0, 50));
localStore.verify(x => x.setInt('contributors.pageSize', 50), Times.atLeastOnce());
expect().nothing();
});
it('should show filtered contributors when searching', () => { it('should show filtered contributors when searching', () => {
contributorsState.load().subscribe(); contributorsState.load().subscribe();
contributorsState.search('4'); contributorsState.search('4');
@ -123,6 +112,19 @@ describe('ContributorsState', () => {
expect(contributorsState.snapshot.contributorsPager.page).toEqual(0); expect(contributorsState.snapshot.contributorsPager.page).toEqual(0);
}); });
it('should load when synchronizer triggered', () => {
const { synchronizer, trigger } = buildDummyStateSynchronizer();
contributorsState.loadAndListen(synchronizer);
trigger();
trigger();
expect().nothing();
contributorsService.verify(x => x.getContributors(app), Times.exactly(2));
});
it('should show notification on load when reload is true', () => { it('should show notification on load when reload is true', () => {
contributorsState.load(true).subscribe(); contributorsState.load(true).subscribe();

27
frontend/app/shared/state/contributors.state.ts

@ -6,7 +6,7 @@
*/ */
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { DialogService, ErrorDto, LocalStoreService, Pager, shareMapSubscribed, shareSubscribed, State, Types, Version } from '@app/framework'; import { DialogService, ErrorDto, Pager, shareMapSubscribed, shareSubscribed, State, StateSynchronizer, Types, Version } from '@app/framework';
import { Observable, throwError } from 'rxjs'; import { Observable, throwError } from 'rxjs';
import { catchError, finalize, tap } from 'rxjs/operators'; import { catchError, finalize, tap } from 'rxjs/operators';
import { AssignContributorDto, ContributorDto, ContributorsPayload, ContributorsService } from './../services/contributors.service'; import { AssignContributorDto, ContributorDto, ContributorsPayload, ContributorsService } from './../services/contributors.service';
@ -31,9 +31,6 @@ interface Snapshot {
// The search query. // The search query.
query?: string; query?: string;
// Query regex.
queryRegex?: RegExp;
// The app version. // The app version.
version: Version; version: Version;
@ -52,7 +49,7 @@ export class ContributorsState extends State<Snapshot> {
this.project(x => x.query); this.project(x => x.query);
public queryRegex = public queryRegex =
this.project(x => x.queryRegex); this.projectFrom(this.query, q => q ? new RegExp(q, 'i') : undefined);
public maxContributors = public maxContributors =
this.project(x => x.maxContributors); this.project(x => x.maxContributors);
@ -78,23 +75,26 @@ export class ContributorsState extends State<Snapshot> {
constructor( constructor(
private readonly appsState: AppsState, private readonly appsState: AppsState,
private readonly contributorsService: ContributorsService, private readonly contributorsService: ContributorsService,
private readonly dialogs: DialogService, private readonly dialogs: DialogService
private readonly localStore: LocalStoreService
) { ) {
super({ super({
contributors: [], contributors: [],
contributorsPager: Pager.fromLocalStore('contributors', localStore), contributorsPager: new Pager(0),
maxContributors: -1, maxContributors: -1,
version: Version.EMPTY version: Version.EMPTY
}); });
}
this.contributorsPager.subscribe(pager => { public loadAndListen(synchronizer: StateSynchronizer) {
pager.saveTo('contributors', this.localStore); synchronizer.mapTo(this)
}); .withString('query', 'q')
.withPager('contributorsPager', 'contributors', 10)
.whenSynced(() => this.loadInternal(false))
.build();
} }
public load(isReload = false): Observable<any> { public load(isReload = false): Observable<any> {
if (isReload) { if (!isReload) {
const contributorsPager = this.snapshot.contributorsPager.reset(); const contributorsPager = this.snapshot.contributorsPager.reset();
this.resetState({ contributorsPager }); this.resetState({ contributorsPager });
@ -125,7 +125,7 @@ export class ContributorsState extends State<Snapshot> {
} }
public search(query: string) { public search(query: string) {
this.next(s => ({ ...s, query, queryRegex: new RegExp(query, 'i') })); this.next(s => ({ ...s, query }));
} }
public revoke(contributor: ContributorDto): Observable<any> { public revoke(contributor: ContributorDto): Observable<any> {
@ -158,6 +158,7 @@ export class ContributorsState extends State<Snapshot> {
const contributorsPager = s.contributorsPager.setCount(contributors.length); const contributorsPager = s.contributorsPager.setCount(contributors.length);
return { return {
...s,
canCreate, canCreate,
contributors, contributors,
contributorsPager, contributorsPager,

6
frontend/app/shared/state/queries.ts

@ -8,7 +8,7 @@
import { compareStrings } from '@app/framework'; import { compareStrings } from '@app/framework';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { map, shareReplay } from 'rxjs/operators'; import { map, shareReplay } from 'rxjs/operators';
import { decodeQuery, equalsQuery, Query } from './query'; import { deserializeQuery, equalsQuery, Query } from './query';
import { UIState } from './ui.state'; import { UIState } from './ui.state';
export interface SavedQuery { export interface SavedQuery {
@ -89,13 +89,13 @@ export class Queries {
} }
function parseQueries(settings: {}) { function parseQueries(settings: {}) {
let queries = Object.keys(settings).map(name => parseStored(name, settings[name])); const queries = Object.keys(settings).map(name => parseStored(name, settings[name]));
return queries.sort((a, b) => compareStrings(a.name, b.name)); return queries.sort((a, b) => compareStrings(a.name, b.name));
} }
export function parseStored(name: string, raw?: string) { export function parseStored(name: string, raw?: string) {
const query = decodeQuery(raw); const query = deserializeQuery(raw);
return { name, query }; return { name, query };
} }

43
frontend/app/shared/state/query.ts

@ -7,7 +7,8 @@
// tslint:disable: readonly-array // tslint:disable: readonly-array
import { Types } from '@app/framework'; import { Params } from '@angular/router';
import { RouteSynchronizer, Types } from '@app/framework';
import { StatusInfo } from './../services/contents.service'; import { StatusInfo } from './../services/contents.service';
import { LanguageDto } from './../services/languages.service'; import { LanguageDto } from './../services/languages.service';
import { MetaFields, SchemaDetailsDto } from './../services/schemas.service'; import { MetaFields, SchemaDetailsDto } from './../services/schemas.service';
@ -112,6 +113,38 @@ const DEFAULT_QUERY = {
sort: [] sort: []
}; };
export class QueryFullTextSynchronizer implements RouteSynchronizer {
public getValue(params: Params) {
const query = params['query'];
if (Types.isString(query)) {
return { fullText: query };
}
}
public writeValue(state: any, params: Params) {
if (Types.isObject(state) && Types.isString(state.fullText) && state.fullText.length > 0) {
params['query'] = state.fullText;
}
}
}
export class QuerySynchronizer implements RouteSynchronizer {
public getValue(params: Params) {
const query = params['query'];
if (Types.isString(query)) {
return deserializeQuery(query);
}
}
public writeValue(state: any, params: Params) {
if (Types.isObject(state)) {
params['query'] = serializeQuery(state);
}
}
}
export function sanitize(query?: Query) { export function sanitize(query?: Query) {
if (!query) { if (!query) {
return DEFAULT_QUERY; return DEFAULT_QUERY;
@ -132,11 +165,15 @@ export function equalsQuery(lhs?: Query, rhs?: Query) {
return Types.equals(sanitize(lhs), sanitize(rhs)); return Types.equals(sanitize(lhs), sanitize(rhs));
} }
export function serializeQuery(query?: Query) {
return JSON.stringify(sanitize(query));
}
export function encodeQuery(query?: Query) { export function encodeQuery(query?: Query) {
return encodeURIComponent(JSON.stringify(sanitize(query))); return encodeURIComponent(serializeQuery(query));
} }
export function decodeQuery(raw?: string): Query | undefined { export function deserializeQuery(raw?: string): Query | undefined {
let query: Query | undefined = undefined; let query: Query | undefined = undefined;
try { try {

27
frontend/app/shared/state/rule-events.state.spec.ts

@ -5,7 +5,7 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { DialogService, LocalStoreService, Pager, RuleEventsDto, RuleEventsState, RulesService } from '@app/shared/internal'; import { DialogService, Pager, RuleEventsDto, RuleEventsState, RulesService } from '@app/shared/internal';
import { of, throwError } from 'rxjs'; import { 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';
@ -26,18 +26,15 @@ describe('RuleEventsState', () => {
let dialogs: IMock<DialogService>; let dialogs: IMock<DialogService>;
let rulesService: IMock<RulesService>; let rulesService: IMock<RulesService>;
let ruleEventsState: RuleEventsState; let ruleEventsState: RuleEventsState;
let localStore: IMock<LocalStoreService>;
beforeEach(() => { beforeEach(() => {
dialogs = Mock.ofType<DialogService>(); dialogs = Mock.ofType<DialogService>();
localStore = Mock.ofType<LocalStoreService>();
rulesService = Mock.ofType<RulesService>(); rulesService = Mock.ofType<RulesService>();
rulesService.setup(x => x.getEvents(app, 10, 0, undefined)) rulesService.setup(x => x.getEvents(app, 10, 0, undefined))
.returns(() => of(new RuleEventsDto(200, oldRuleEvents))); .returns(() => of(new RuleEventsDto(200, oldRuleEvents)));
ruleEventsState = new RuleEventsState(appsState.object, dialogs.object, localStore.object, rulesService.object); ruleEventsState = new RuleEventsState(appsState.object, dialogs.object, rulesService.object);
ruleEventsState.load().subscribe(); ruleEventsState.load().subscribe();
}); });
@ -59,15 +56,6 @@ describe('RuleEventsState', () => {
expect(ruleEventsState.snapshot.isLoading).toBeFalsy(); expect(ruleEventsState.snapshot.isLoading).toBeFalsy();
}); });
it('should load page size from local store', () => {
localStore.setup(x => x.getInt('rule-events.pageSize', 10))
.returns(() => 25);
const state = new RuleEventsState(appsState.object, dialogs.object, localStore.object, rulesService.object);
expect(state.snapshot.ruleEventsPager.pageSize).toBe(25);
});
it('should show notification on load when reload is true', () => { it('should show notification on load when reload is true', () => {
ruleEventsState.load(true).subscribe(); ruleEventsState.load(true).subscribe();
@ -88,17 +76,6 @@ describe('RuleEventsState', () => {
rulesService.verify(x => x.getEvents(app, 10, 0, undefined), Times.once()); rulesService.verify(x => x.getEvents(app, 10, 0, undefined), Times.once());
}); });
it('should update page size in local store', () => {
rulesService.setup(x => x.getEvents(app, 50, 0, undefined))
.returns(() => of(new RuleEventsDto(200, [])));
ruleEventsState.setPager(new Pager(200, 0, 50)).subscribe();
localStore.verify(x => x.setInt('rule-events.pageSize', 50), Times.atLeastOnce());
expect().nothing();
});
it('should load with rule id when filtered', () => { it('should load with rule id when filtered', () => {
rulesService.setup(x => x.getEvents(app, 10, 0, '12')) rulesService.setup(x => x.getEvents(app, 10, 0, '12'))
.returns(() => of(new RuleEventsDto(200, []))); .returns(() => of(new RuleEventsDto(200, [])));

15
frontend/app/shared/state/rule-events.state.ts

@ -6,7 +6,7 @@
*/ */
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { DialogService, LocalStoreService, Pager, shareSubscribed, State } from '@app/framework'; import { DialogService, Pager, Router2State, shareSubscribed, State } from '@app/framework';
import { empty, Observable } from 'rxjs'; import { empty, Observable } from 'rxjs';
import { finalize, tap } from 'rxjs/operators'; import { finalize, tap } from 'rxjs/operators';
import { RuleEventDto, RulesService } from './../services/rules.service'; import { RuleEventDto, RulesService } from './../services/rules.service';
@ -46,17 +46,20 @@ export class RuleEventsState extends State<Snapshot> {
constructor( constructor(
private readonly appsState: AppsState, private readonly appsState: AppsState,
private readonly dialogs: DialogService, private readonly dialogs: DialogService,
private readonly localStore: LocalStoreService,
private readonly rulesService: RulesService private readonly rulesService: RulesService
) { ) {
super({ super({
ruleEvents: [], ruleEvents: [],
ruleEventsPager: Pager.fromLocalStore('rule-events', localStore) ruleEventsPager: new Pager(0)
}); });
}
this.ruleEventsPager.subscribe(pager => { public loadAndListen(route: Router2State) {
pager.saveTo('rule-events', this.localStore); route.mapTo(this)
}); .withPager('ruleEventsPager', 'ruleEvents', 30)
.withString('ruleId', 'ruleId')
.whenSynced(() => this.loadInternal(false))
.build();
} }
public load(isReload = false): Observable<any> { public load(isReload = false): Observable<any> {

2
frontend/app/shared/state/ui.state.ts

@ -209,7 +209,7 @@ export class UIState extends State<Snapshot> {
let current = setting; let current = setting;
for (const segment of segments) { for (const segment of segments) {
let temp = current[segment]; const temp = current[segment];
if (temp) { if (temp) {
current[segment] = temp; current[segment] = temp;

1
frontend/package.json

@ -10,6 +10,7 @@
"test:coverage": "karma start karma.coverage.conf.js", "test:coverage": "karma start karma.coverage.conf.js",
"test:clean": "rimraf _test-output", "test:clean": "rimraf _test-output",
"tslint": "tslint -c tslint.json -p tsconfig.json app/**/*.ts", "tslint": "tslint -c tslint.json -p tsconfig.json app/**/*.ts",
"tslint-fix": "tslint -c tslint.json -p tsconfig.json app/**/*.ts -t verbose --fix",
"build": "node --max_old_space_size=4096 node_modules/webpack/bin/webpack.js --config app-config/webpack.config.js --env.production", "build": "node --max_old_space_size=4096 node_modules/webpack/bin/webpack.js --config app-config/webpack.config.js --env.production",
"build:clean": "rimraf wwwroot/build", "build:clean": "rimraf wwwroot/build",
"build:analyze": "node --max_old_space_size=4096 node_modules/webpack/bin/webpack.js --config app-config/webpack.config.js --env.production --env.analyze", "build:analyze": "node --max_old_space_size=4096 node_modules/webpack/bin/webpack.js --config app-config/webpack.config.js --env.production --env.analyze",

1
frontend/tslint.json

@ -92,6 +92,7 @@
true, true,
"sqx" "sqx"
], ],
"prefer-const": true,
"prefer-for-of": true, "prefer-for-of": true,
"quotemark": [ "quotemark": [
true, true,

Loading…
Cancel
Save