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)]
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(() =>
{
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);
}

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

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using Squidex.Domain.Apps.Entities.Assets;
@ -26,14 +27,22 @@ namespace Squidex.Areas.Api.Controllers.Assets.Models
[Required]
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
{
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);
}

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">
<img class="user-picture" title="{{user.displayName}}" [src]="user | sqxUserDtoPicture" />
</td>

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

@ -8,22 +8,32 @@
import { Component, OnInit } from '@angular/core';
import { FormControl } from '@angular/forms';
import { UserDto, UsersState } from '@app/features/administration/internal';
import { ResourceOwner, Router2State } from '@app/framework';
@Component({
selector: 'sqx-users-page',
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();
constructor(
public readonly usersRoute: Router2State,
public readonly usersState: UsersState
) {
super();
this.own(
this.usersState.usersQuery
.subscribe(q => this.usersFilter.setValue(q || '')));
}
public ngOnInit() {
this.usersState.load();
this.usersState.loadAndListen(this.usersRoute);
}
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 { DialogService, LocalStoreService, Pager } from '@app/shared';
import { DialogService, Pager } from '@app/shared';
import { of, throwError } from 'rxjs';
import { onErrorResumeNext } from 'rxjs/operators';
import { IMock, It, Mock, Times } from 'typemoq';
import { TestValues } from './../../../shared/state/_test-helpers';
import { createUser } from './../services/users.service.spec';
import { UsersState } from './users.state';
describe('UsersState', () => {
const { buildDummyStateSynchronizer } = TestValues;
const user1 = createUser(1);
const user2 = createUser(2);
@ -22,17 +25,14 @@ describe('UsersState', () => {
const newUser = createUser(3);
let dialogs: IMock<DialogService>;
let localStore: IMock<LocalStoreService>;
let usersService: IMock<UsersService>;
let usersState: UsersState;
beforeEach(() => {
dialogs = Mock.ofType<DialogService>();
localStore = Mock.ofType<LocalStoreService>();
usersService = Mock.ofType<UsersService>();
usersState = new UsersState(dialogs.object, localStore.object, usersService.object);
usersState = new UsersState(dialogs.object, usersService.object);
});
afterEach(() => {
@ -63,15 +63,6 @@ describe('UsersState', () => {
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', () => {
usersService.setup(x => x.getUsers(10, 0, undefined))
.returns(() => of(oldUsers)).verifiable();
@ -111,17 +102,6 @@ describe('UsersState', () => {
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', () => {
usersService.setup(x => x.getUsers(10, 0, 'my-query'))
.returns(() => of(new UsersDto(0, []))).verifiable();
@ -130,6 +110,20 @@ describe('UsersState', () => {
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', () => {

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

@ -7,7 +7,7 @@
import { Injectable } from '@angular/core';
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 { catchError, finalize, tap } from 'rxjs/operators';
import { CreateUserDto, UpdateUserDto, UserDto, UsersService } from './../services/users.service';
@ -46,6 +46,9 @@ export class UsersState extends State<Snapshot> {
public usersPager =
this.project(x => x.usersPager);
public usersQuery =
this.project(x => x.usersQuery);
public selectedUser =
this.project(x => x.selectedUser);
@ -60,16 +63,11 @@ export class UsersState extends State<Snapshot> {
constructor(
private readonly dialogs: DialogService,
private readonly localStore: LocalStoreService,
private readonly usersService: UsersService
) {
super({
users: [],
usersPager: Pager.fromLocalStore('users', localStore)
});
this.usersPager.subscribe(pager => {
pager.saveTo('users', this.localStore);
usersPager: new Pager(0)
});
}
@ -95,11 +93,18 @@ export class UsersState extends State<Snapshot> {
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> {
if (!isReload) {
const usersPager = this.snapshot.usersPager.reset();
this.resetState({ usersPager, selectedUser: this.snapshot.selectedUser });
this.resetState({ selectedUser: this.snapshot.selectedUser });
}
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 { FormControl } from '@angular/forms';
import { ActivatedRoute } from '@angular/router';
import { AssetsState, DialogModel, LocalStoreService, Queries, Query, ResourceOwner, UIState } from '@app/shared';
import { AssetsState, DialogModel, LocalStoreService, Queries, Query, ResourceOwner, Router2State, UIState } from '@app/shared';
@Component({
selector: 'sqx-assets-page',
styleUrls: ['./assets-page.component.scss'],
templateUrl: './assets-page.component.html'
templateUrl: './assets-page.component.html',
providers: [
Router2State
]
})
export class AssetsPageComponent extends ResourceOwner implements OnInit {
public assetsFilter = new FormControl();
public queries = new Queries(this.uiState, 'assets');
public addAssetFolderDialog = new DialogModel();
@ -25,9 +24,9 @@ export class AssetsPageComponent extends ResourceOwner implements OnInit {
public isListView: boolean;
constructor(
public readonly assetsRoute: Router2State,
public readonly assetsState: AssetsState,
private readonly localStore: LocalStoreService,
private readonly route: ActivatedRoute,
private readonly uiState: UIState
) {
super();
@ -36,11 +35,7 @@ export class AssetsPageComponent extends ResourceOwner implements OnInit {
}
public ngOnInit() {
this.own(
this.route.queryParams
.subscribe(p => {
this.assetsState.search({ fullText: p['query'] });
}));
this.assetsState.loadAndListen(this.assetsRoute);
}
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.
*/
// tslint:disable: max-line-length
import { Component, OnInit, ViewChild } from '@angular/core';
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 { onErrorResumeNext, switchMap, tap } from 'rxjs/operators';
import { AppLanguageDto, ContentDto, ContentsState, fadeAnimation, LanguagesState, ModalModel, Queries, Query, QueryModel, queryModelFromSchema, ResourceOwner, Router2State, SchemaDetailsDto, SchemasState, TableFields, TempService, UIState } from '@app/shared';
import { combineLatest } from 'rxjs';
import { distinctUntilChanged, onErrorResumeNext, switchMap, tap } from 'rxjs/operators';
import { DueTimeSelectorComponent } from './../../shared/due-time-selector.component';
@Component({
selector: 'sqx-contents-page',
styleUrls: ['./contents-page.component.scss'],
templateUrl: './contents-page.component.html',
providers: [
Router2State
],
animations: [
fadeAnimation
]
@ -45,6 +51,7 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit {
public dueTimeSelector: DueTimeSelectorComponent;
constructor(
public readonly contentsRoute: Router2State,
public readonly contentsState: ContentsState,
private readonly route: ActivatedRoute,
private readonly router: Router,
@ -58,23 +65,26 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit {
public ngOnInit() {
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 => {
this.resetSelection();
this.schema = schema;
this.contentsState.load();
this.updateQueries();
this.updateModel();
this.updateTable();
}));
this.own(
this.contentsState.statuses
.subscribe(() => {
this.updateModel();
this.contentsState.loadAndListen(this.contentsRoute);
}));
this.own(
@ -89,8 +99,6 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit {
this.languages = languages.map(x => x.language);
this.language = this.languages.find(x => x.isMaster)!;
this.languageMaster = this.language;
this.updateModel();
}));
}
@ -138,14 +146,13 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit {
this.contentsState.search(query);
}
public isItemSelected(content: ContentDto): boolean {
return this.selectedItems[content.id] === true;
}
private selectItems(predicate?: (content: ContentDto) => boolean) {
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) {
this.language = language;
}
@ -166,7 +173,7 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit {
this.selectedItems = {};
if (isSelected) {
for (let content of this.contentsState.snapshot.contents) {
for (const content of this.contentsState.snapshot.contents) {
this.selectedItems[content.id] = true;
}
}
@ -174,7 +181,7 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit {
this.updateSelectionSummary();
}
public trackByContent(index: number, content: ContentDto): string {
public trackByContent(content: ContentDto): string {
return content.id;
}
@ -184,13 +191,13 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit {
this.selectionCanDelete = true;
this.nextStatuses = {};
for (let content of this.contentsState.snapshot.contents) {
for (const content of this.contentsState.snapshot.contents) {
for (const info of content.statusUpdates) {
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]) {
this.selectionCount++;
@ -220,10 +227,4 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit {
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) {
let controls = [...this.arrayControl.controls];
const controls = [...this.arrayControl.controls];
controls.splice(controls.indexOf(control), 1);
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() {
const fields: FieldControl[] = [];
for (let field of this.field.nested) {
for (const field of this.field.nested) {
const control = this.itemForm.get(field.name)!;
if (control || this.field.properties.isContentField) {
@ -122,7 +122,7 @@ export class ArrayItemComponent implements OnChanges, OnDestroy {
private updateTitle() {
const values: string[] = [];
for (let { control, field } of this.fieldControls) {
for (const { control, field } of this.fieldControls) {
const formatted = FieldFormatter.format(field, control.value);
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>) {
let result = 0;
for (let field of fields) {
for (const field of fields) {
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 { ActivatedRoute } from '@angular/router';
import { ResourceOwner, RuleEventDto, RuleEventsState } from '@app/shared';
import { Router2State, RuleEventDto, RuleEventsState } from '@app/shared';
@Component({
selector: 'sqx-rule-events-page',
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;
constructor(
public readonly ruleEventsState: RuleEventsState,
private readonly route: ActivatedRoute
public readonly ruleEventsRoute: Router2State,
public readonly ruleEventsState: RuleEventsState
) {
super();
}
public ngOnInit() {
this.own(
this.route.queryParams
.subscribe(x => {
this.ruleEventsState.filterByRule(x.ruleId);
}));
this.ruleEventsState.load();
this.ruleEventsState.loadAndListen(this.ruleEventsRoute);
}
public reload() {

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

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

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

@ -6,17 +6,21 @@
*/
import { Component, OnInit } from '@angular/core';
import { ContributorDto, ContributorsState, DialogModel, RolesState } from '@app/shared';
import { ContributorDto, ContributorsState, DialogModel, RolesState, Router2State } from '@app/shared';
@Component({
selector: 'sqx-contributors-page',
styleUrls: ['./contributors-page.component.scss'],
templateUrl: './contributors-page.component.html'
templateUrl: './contributors-page.component.html',
providers: [
Router2State
]
})
export class ContributorsPageComponent implements OnInit {
public importDialog = new DialogModel();
constructor(
public readonly contributorsRoute: Router2State,
public readonly contributorsState: ContributorsState,
public readonly rolesState: RolesState
) {
@ -25,7 +29,7 @@ export class ContributorsPageComponent implements OnInit {
public ngOnInit() {
this.rolesState.load();
this.contributorsState.load();
this.contributorsState.loadAndListen(this.contributorsRoute);
}
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;
for (let value of this.valuesSorted) {
for (const value of this.valuesSorted) {
width += 30;
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) {
let { isDisabled, isChecked } = this.snapshot;
const isDisabled = this.snapshot.isDisabled;
if (isDisabled) {
return;
}
let isChecked = this.snapshot.isChecked;
if (this.threeStates && (event.ctrlKey || event.shiftKey)) {
if (isChecked) {
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, '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') {
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 {
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 p = v * (1 - 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) {
let u = 0, s = 1024;
export function calculateFileSize(value: number, factor = 1024) {
let u = 0;
while (value >= s || -value >= s) {
value /= s;
while (value >= factor || -value >= factor) {
value /= factor;
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';
describe('ShortcutComponent', () => {
let changeDetector: any = {
const changeDetector: any = {
detach: () => {
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/routers/can-deactivate.guard';
export * from './angular/routers/parent-link.directive';
export * from './angular/routers/router-2-state';
export * from './angular/safe-html.pipe';
export * from './angular/scroll-active.directive';
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';
describe('LoadingService', () => {
let events = new Subject<Event>();
const events = new Subject<Event>();
it('should instantiate from factory', () => {
const loadingService = LoadingServiceFactory(<any>{ events });

2
frontend/app/framework/state.ts

@ -132,7 +132,7 @@ export class Model<T> {
for (const key in values) {
if (values.hasOwnProperty(key)) {
let value = values[key];
const value = values[key];
if (value || !validOnly) {
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 }
];
for (let test of tests) {
for (const test of tests) {
const modalRect = buildRect(0, 0, 30, 30);
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.
*/
import { Mock, Times } from 'typemoq';
import { LocalStoreService } from './../services/local-store.service';
import { Pager } from './pager';
describe('Pager', () => {
@ -213,27 +211,4 @@ describe('Pager', () => {
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.
*/
import { LocalStoreService } from './../services/local-store.service';
export class Pager {
public canGoNext = false;
public canGoPrev = false;
@ -19,11 +17,12 @@ export class Pager {
constructor(
public readonly numberOfItems: number,
public readonly page = 0,
public readonly pageSize = 10
public readonly pageSize = 10,
unsafe = false
) {
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;
}
@ -36,20 +35,6 @@ export class Pager {
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 {
if (!this.canGoNext) {
return this;

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

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

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

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

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

@ -30,7 +30,7 @@
<div class="folder-container-over"></div>
<sqx-asset-folder [assetFolder]="parent"
(navigate)="state.navigate($event)">
(navigate)="state.navigate($event.id)">
</sqx-asset-folder>
</div>
@ -46,7 +46,7 @@
cdkDrag
[cdkDragData]="assetFolder"
[cdkDragDisabled]="isDisabled || !assetFolder.canMove"
(navigate)="state.navigate($event)"
(navigate)="state.navigate($event.id)"
(delete)="deleteAssetFolder($event)">
</sqx-asset-folder>
</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.y = newFocus.y;
for (let preview of this.previewImages) {
for (const preview of this.previewImages) {
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) {
if (changes['queryModel'] && !changes['query']) {
this.query = {};
}
if (changes['query'] || changes['queries']) {
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', () => {
let router: IMock<Router>;
let authService: IMock<AuthService>;
let uiOptions = new UIOptions({ map: { type: 'OSM' } });
let uiOptionsRedirect = new UIOptions({ map: { type: 'OSM' }, redirectToLogin: true });
const uiOptions = new UIOptions({ map: { type: 'OSM' } });
const uiOptionsRedirect = new UIOptions({ map: { type: 'OSM' }, redirectToLogin: true });
beforeEach(() => {
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', () => {
let router: IMock<Router>;
let authService: IMock<AuthService>;
let uiOptions = new UIOptions({ map: { type: 'OSM' } });
let uiOptionsRedirect = new UIOptions({ map: { type: 'OSM' }, redirectToLogin: true });
const uiOptions = new UIOptions({ map: { type: 'OSM' } });
const uiOptionsRedirect = new UIOptions({ map: { type: 'OSM' }, redirectToLogin: true });
beforeEach(() => {
router = Mock.ofType<Router>();

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

@ -108,6 +108,9 @@ describe('AssetsService', () => {
items: [
assetFolderResponse(22),
assetFolderResponse(23)
],
path: [
assetFolderResponse(44)
]
});
@ -115,6 +118,8 @@ describe('AssetsService', () => {
new AssetFoldersDto(10, [
createAssetFolder(22),
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> {
constructor(total: number, items: ReadonlyArray<AssetFolderDto>,
public readonly path: ReadonlyArray<AssetFolderDto>,
links?: ResourceLinks
) {
super(total, items, links);
}
public get canCreate() {
return hasAnyLink(this._links, 'create');
}
@ -176,7 +183,7 @@ export class AssetsService {
}
if (tags) {
for (let tag of tags) {
for (const tag of tags) {
if (tag && tag.length > 0) {
filters.push({ path: 'tags', op: 'eq', value: tag });
}
@ -240,11 +247,12 @@ export class AssetsService {
public getAssetFolders(appName: string, parentId?: string): Observable<AssetFoldersDto> {
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(
map(({ total, items, _links }) => {
return this.http.get<{ total: number, items: any[], folders: any[], path: any[] } & Resource>(url).pipe(
map(({ total, items, path, _links }) => {
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.'));
}

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

@ -51,7 +51,9 @@ export class AutoSaveService {
}
function getKey(key: AutoSaveKey) {
let { contentId, schemaId, schemaVersion } = key;
const { schemaId, schemaVersion } = key;
let contentId = key.contentId;
if (!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)) {
const body: any = {};

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

@ -218,7 +218,7 @@ export class RulesService {
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 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[] {
let result: TableField[] = [];
const result: TableField[] = [];
for (let name of names) {
for (const name of names) {
if (name.startsWith('meta.')) {
result.push(name);
} else {

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

@ -84,7 +84,7 @@ export class UsagesService {
map(body => {
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) =>
new CallsUsagePerDateDto(
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 => {
if (transition.from === name || transition.to === name) {
let newTransition = { ...transition };
const newTransition = { ...transition };
if (newTransition.from === name) {
newTransition.from = newName;
@ -178,12 +178,12 @@ export class WorkflowDto extends Model<WorkflowDto> {
public serialize(): any {
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 s = { ...values, transitions: {} };
for (let transition of this.getTransitions(step)) {
for (const transition of this.getTransitions(step)) {
const { from, to, step: _, ...t } = transition;
s.transitions[to] = t;
@ -288,13 +288,13 @@ function parseWorkflow(workflow: any) {
const steps: WorkflowStep[] = [];
const transitions: WorkflowTransition[] = [];
for (let stepName in workflow.steps) {
for (const stepName in workflow.steps) {
if (workflow.steps.hasOwnProperty(stepName)) {
const { transitions: srcTransitions, ...step } = workflow.steps[stepName];
steps.push({ name: stepName, isLocked: stepName === 'Published', ...step });
for (let to in srcTransitions) {
for (const to in srcTransitions) {
if (srcTransitions.hasOwnProperty(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.
*/
import { of } from 'rxjs';
import { StateSynchronizer, StateSynchronizerMap } from '@app/framework';
import { of, Subject } from 'rxjs';
import { Mock } from 'typemoq';
import { AppsState, AuthService, DateTime, Version } from './../';
@ -33,10 +34,64 @@ const authService = Mock.ofType<AuthService>();
authService.setup(x => x.user)
.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 = {
app,
appsState,
authService,
buildDummyStateSynchronizer,
creation,
creator,
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', () => {
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))
.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) {
const result = { ...value, metadata: {} };
for (let item of value.metadata) {
for (const item of value.metadata) {
const raw = item.value;
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.
*/
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 { onErrorResumeNext } from 'rxjs/operators';
import { IMock, It, Mock, Times } from 'typemoq';
@ -16,6 +16,7 @@ describe('AssetsState', () => {
const {
app,
appsState,
buildDummyStateSynchronizer,
newVersion
} = TestValues;
@ -30,20 +31,15 @@ describe('AssetsState', () => {
let dialogs: IMock<DialogService>;
let assetsService: IMock<AssetsService>;
let assetsState: AssetsState;
let localStore: IMock<LocalStoreService>;
beforeEach(() => {
dialogs = Mock.ofType<DialogService>();
localStore = Mock.ofType<LocalStoreService>();
localStore.setup(x => x.getInt('assets.pageSize', 30))
.returns(() => 30);
assetsService = Mock.ofType<AssetsService>();
assetsService.setup(x => x.getTags(app))
.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(() => {
@ -53,7 +49,7 @@ describe('AssetsState', () => {
describe('Loading', () => {
beforeEach(() => {
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', () => {
@ -112,58 +108,32 @@ describe('AssetsState', () => {
expect().nothing();
});
it('should update page size in local store', () => {
assetsService.setup(x => x.getAssets(app, { take: 50, skip: 0, parentId: MathHelper.EMPTY_GUID }))
.returns(() => of(new AssetsDto(200, []))).verifiable();
it('should load when synchronizer triggered', () => {
const { synchronizer, trigger } = buildDummyStateSynchronizer();
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();
});
});
describe('Navigating', () => {
beforeEach(() => {
assetsService.setup(x => x.getAssets(app, It.isAny()))
.returns(() => of(new AssetsDto(0, [])));
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();
it('should load with parent id', () => {
assetsService.setup(x => x.getAssetFolders(app, '123'))
.returns(() => of(new AssetFoldersDto(2, [assetFolder1, assetFolder2], []))).verifiable();
let path: ReadonlyArray<AssetPathItem>;
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>;
assetsService.setup(x => x.getAssets(app, { take: 30, skip: 0, parentId: '123' }))
.returns(() => of(new AssetsDto(200, []))).verifiable();
assetsState.path.subscribe(result => {
path = result;
});
assetsState.navigate('123').subscribe();
expect(path!).toEqual([
{ id: MathHelper.EMPTY_GUID, folderName: 'Assets' }
]);
expect().nothing();
});
});
@ -201,7 +171,7 @@ describe('AssetsState', () => {
describe('Updates', () => {
beforeEach(() => {
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 }))
.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 { 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 { catchError, finalize, tap } from 'rxjs/operators';
import { AnnotateAssetDto, AssetDto, AssetFolderDto, AssetsService, RenameAssetFolderDto } from './../services/assets.service';
import { AppsState } from './apps.state';
import { Query } from './query';
import { Query, QueryFullTextSynchronizer } from './query';
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 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 {
// All assets tags.
@ -46,7 +46,10 @@ interface Snapshot {
path: ReadonlyArray<AssetPathItem>;
// The parent folder.
parentFolder?: AssetPathItem;
parentId: string;
// Indicates if the assets are loaded once.
isLoadedOnce?: boolean;
// Indicates if the assets are loaded.
isLoaded?: boolean;
@ -103,10 +106,7 @@ export class AssetsState extends State<Snapshot> {
this.project(x => x.path.length > 0);
public parentFolder =
this.project(x => x.parentFolder);
public pathRoot =
this.project(x => x.path[x.path.length - 1]);
this.project(x => getParent(x.path));
public canCreate =
this.project(x => x.canCreate === true);
@ -117,21 +117,27 @@ export class AssetsState extends State<Snapshot> {
constructor(
private readonly appsState: AppsState,
private readonly assetsService: AssetsService,
private readonly dialogs: DialogService,
private readonly localStore: LocalStoreService
private readonly dialogs: DialogService
) {
super({
assetFolders: [],
assets: [],
assetsPager: Pager.fromLocalStore('assets', localStore, 30),
path: ROOT_PATH,
assetsPager: new Pager(0, 0, 30),
parentId: ROOT_ITEM.id,
path: [ROOT_ITEM],
tagsAvailable: {},
tagsSelected: {}
});
}
this.assetsPager.subscribe(pager => {
pager.saveTo('assets', this.localStore);
});
public loadAndListen(synchronizer: StateSynchronizer) {
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> {
@ -150,30 +156,32 @@ export class AssetsState extends State<Snapshot> {
skip: this.snapshot.assetsPager.skip
};
if (this.parentId) {
query.parentId = this.parentId;
}
const withQuery = hasQuery(this.snapshot);
if (this.snapshot.assetsQuery) {
query.query = this.snapshot.assetsQuery;
}
if (withQuery) {
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) {
query.tags = searchTags;
if (searchTags.length > 0) {
query.tags = searchTags;
}
} else {
query.parentId = this.snapshot.parentId;
}
const assets$ =
this.assetsService.getAssets(this.appName, query);
const assetFolders$ =
this.snapshot.path.length === 0 ?
of(EMPTY_FOLDERS) :
this.assetsService.getAssetFolders(this.appName, this.parentId);
!withQuery ?
this.assetsService.getAssetFolders(this.appName, this.snapshot.parentId) :
of(EMPTY_FOLDERS);
const tags$ =
this.snapshot.path.length === 1 ?
!withQuery || !this.snapshot.isLoadedOnce ?
this.assetsService.getTags(this.appName) :
of(this.snapshot.tagsAvailable);
@ -183,6 +191,10 @@ export class AssetsState extends State<Snapshot> {
this.dialogs.notifyInfo('Assets reloaded.');
}
const path = assetFolders.path ?
[ROOT_ITEM, ...assetFolders.path] :
[];
this.next(s => ({
...s,
assetFolders: assetFolders.items,
@ -191,8 +203,9 @@ export class AssetsState extends State<Snapshot> {
canCreate: assets.canCreate,
canCreateFolders: assetFolders.canCreate,
isLoaded: true,
isLoadedOnce: true,
isLoading: false,
parentFolder: getParent(s.path),
path,
tagsAvailable
}));
}),
@ -203,7 +216,7 @@ export class AssetsState extends State<Snapshot> {
}
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;
}
@ -218,9 +231,9 @@ export class AssetsState extends State<Snapshot> {
}
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 => {
if (assetFolder.parentId !== this.parentId) {
if (assetFolder.parentId !== this.snapshot.parentId) {
return;
}
@ -334,6 +347,12 @@ export class AssetsState extends State<Snapshot> {
shareSubscribed(this.dialogs));
}
public navigate(parentId: string) {
this.next({ parentId });
return this.loadInternal(false);
}
public setPager(assetsPager: Pager) {
this.next({ assetsPager });
@ -352,13 +371,6 @@ export class AssetsState extends State<Snapshot> {
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;
});
@ -387,24 +399,6 @@ export class AssetsState extends State<Snapshot> {
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> {
return this.searchInternal(null, {});
}
@ -414,7 +408,7 @@ export class AssetsState extends State<Snapshot> {
}
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() {
@ -458,6 +452,10 @@ function sort(tags: { [name: string]: number }) {
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>) {
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);
if (form) {
for (let key in test) {
for (const key in test) {
if (test.hasOwnProperty(key)) {
const a = form[key];
const e = test[key];

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

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

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

@ -6,7 +6,7 @@
*/
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 { catchError, onErrorResumeNext } from 'rxjs/operators';
import { IMock, It, Mock, Times } from 'typemoq';
@ -17,11 +17,12 @@ describe('ContributorsState', () => {
const {
app,
appsState,
buildDummyStateSynchronizer,
newVersion,
version
} = TestValues;
let allIds: number[] = [];
const allIds: number[] = [];
for (let i = 1; i <= 20; i++) {
allIds.push(i);
@ -32,18 +33,15 @@ describe('ContributorsState', () => {
let dialogs: IMock<DialogService>;
let contributorsService: IMock<ContributorsService>;
let contributorsState: ContributorsState;
let localStore: IMock<LocalStoreService>;
beforeEach(() => {
dialogs = Mock.ofType<DialogService>();
localStore = Mock.ofType<LocalStoreService>();
contributorsService = Mock.ofType<ContributorsService>();
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(() => {
@ -100,15 +98,6 @@ describe('ContributorsState', () => {
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', () => {
contributorsState.load().subscribe();
contributorsState.search('4');
@ -123,6 +112,19 @@ describe('ContributorsState', () => {
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', () => {
contributorsState.load(true).subscribe();

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

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

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

@ -8,7 +8,7 @@
import { compareStrings } from '@app/framework';
import { Observable } from 'rxjs';
import { map, shareReplay } from 'rxjs/operators';
import { decodeQuery, equalsQuery, Query } from './query';
import { deserializeQuery, equalsQuery, Query } from './query';
import { UIState } from './ui.state';
export interface SavedQuery {
@ -89,13 +89,13 @@ export class Queries {
}
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));
}
export function parseStored(name: string, raw?: string) {
const query = decodeQuery(raw);
const query = deserializeQuery(raw);
return { name, query };
}

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

@ -7,7 +7,8 @@
// 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 { LanguageDto } from './../services/languages.service';
import { MetaFields, SchemaDetailsDto } from './../services/schemas.service';
@ -112,6 +113,38 @@ const DEFAULT_QUERY = {
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) {
if (!query) {
return DEFAULT_QUERY;
@ -132,11 +165,15 @@ export function equalsQuery(lhs?: Query, rhs?: Query) {
return Types.equals(sanitize(lhs), sanitize(rhs));
}
export function serializeQuery(query?: Query) {
return JSON.stringify(sanitize(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;
try {

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

@ -5,7 +5,7 @@
* 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 { onErrorResumeNext } from 'rxjs/operators';
import { IMock, It, Mock, Times } from 'typemoq';
@ -26,18 +26,15 @@ describe('RuleEventsState', () => {
let dialogs: IMock<DialogService>;
let rulesService: IMock<RulesService>;
let ruleEventsState: RuleEventsState;
let localStore: IMock<LocalStoreService>;
beforeEach(() => {
dialogs = Mock.ofType<DialogService>();
localStore = Mock.ofType<LocalStoreService>();
rulesService = Mock.ofType<RulesService>();
rulesService.setup(x => x.getEvents(app, 10, 0, undefined))
.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();
});
@ -59,15 +56,6 @@ describe('RuleEventsState', () => {
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', () => {
ruleEventsState.load(true).subscribe();
@ -88,17 +76,6 @@ describe('RuleEventsState', () => {
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', () => {
rulesService.setup(x => x.getEvents(app, 10, 0, '12'))
.returns(() => of(new RuleEventsDto(200, [])));

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

@ -6,7 +6,7 @@
*/
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 { finalize, tap } from 'rxjs/operators';
import { RuleEventDto, RulesService } from './../services/rules.service';
@ -46,17 +46,20 @@ export class RuleEventsState extends State<Snapshot> {
constructor(
private readonly appsState: AppsState,
private readonly dialogs: DialogService,
private readonly localStore: LocalStoreService,
private readonly rulesService: RulesService
) {
super({
ruleEvents: [],
ruleEventsPager: Pager.fromLocalStore('rule-events', localStore)
ruleEventsPager: new Pager(0)
});
}
this.ruleEventsPager.subscribe(pager => {
pager.saveTo('rule-events', this.localStore);
});
public loadAndListen(route: Router2State) {
route.mapTo(this)
.withPager('ruleEventsPager', 'ruleEvents', 30)
.withString('ruleId', 'ruleId')
.whenSynced(() => this.loadInternal(false))
.build();
}
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;
for (const segment of segments) {
let temp = current[segment];
const temp = current[segment];
if (temp) {
current[segment] = temp;

1
frontend/package.json

@ -10,6 +10,7 @@
"test:coverage": "karma start karma.coverage.conf.js",
"test:clean": "rimraf _test-output",
"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: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",

1
frontend/tslint.json

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

Loading…
Cancel
Save