From 3b479d0c400cf5e5e219134c24d7e97232da62bf Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Mon, 15 Jun 2020 22:12:31 +0200 Subject: [PATCH] Feature/route sync (#535) * Sync state with routes --- .../Assets/AssetFoldersController.cs | 9 +- .../Assets/Models/AssetFoldersDto.cs | 13 +- .../pages/users/user.component.html | 2 +- .../pages/users/users-page.component.ts | 16 +- .../administration/state/users.state.spec.ts | 44 +-- .../administration/state/users.state.ts | 25 +- .../assets/pages/assets-page.component.ts | 19 +- .../pages/contents/contents-page.component.ts | 55 +-- .../shared/forms/array-editor.component.ts | 2 +- .../shared/forms/array-item.component.ts | 4 +- .../list/content-list-cell.directive.ts | 2 +- .../events/rule-events-page.component.ts | 23 +- .../contributors-page.component.html | 2 +- .../contributors-page.component.ts | 10 +- .../forms/editors/checkbox-group.component.ts | 2 +- .../angular/forms/editors/toggle.component.ts | 4 +- .../modals/modal-placement.directive.ts | 2 +- .../framework/angular/pipes/colors.pipes.ts | 2 +- .../framework/angular/pipes/numbers.pipes.ts | 8 +- .../angular/routers/router-2-state.spec.ts | 337 ++++++++++++++++++ .../angular/routers/router-2-state.ts | 299 ++++++++++++++++ .../angular/shortcut.component.spec.ts | 2 +- frontend/app/framework/declarations.ts | 1 + .../services/loading.service.spec.ts | 2 +- frontend/app/framework/state.ts | 2 +- .../framework/utils/modal-positioner.spec.ts | 2 +- frontend/app/framework/utils/pager.spec.ts | 25 -- frontend/app/framework/utils/pager.ts | 21 +- frontend/app/framework/utils/tag-values.ts | 2 +- frontend/app/framework/utils/types.ts | 4 +- .../assets/assets-list.component.html | 4 +- .../assets/image-focus-point.component.ts | 2 +- .../search/search-form.component.ts | 4 - .../must-be-authenticated.guard.spec.ts | 4 +- .../must-be-not-authenticated.guard.spec.ts | 4 +- .../shared/services/assets.service.spec.ts | 5 + .../app/shared/services/assets.service.ts | 16 +- .../app/shared/services/autosave.service.ts | 4 +- .../app/shared/services/contents.service.ts | 2 +- frontend/app/shared/services/rules.service.ts | 2 +- .../app/shared/services/schemas.service.ts | 4 +- .../app/shared/services/usages.service.ts | 2 +- .../app/shared/services/workflows.service.ts | 10 +- frontend/app/shared/state/_test-helpers.ts | 57 ++- .../shared/state/asset-uploader.state.spec.ts | 2 +- frontend/app/shared/state/assets.forms.ts | 2 +- .../app/shared/state/assets.state.spec.ts | 70 ++-- frontend/app/shared/state/assets.state.ts | 116 +++--- .../app/shared/state/contents.forms.spec.ts | 2 +- frontend/app/shared/state/contents.state.ts | 40 +-- .../shared/state/contributors.state.spec.ts | 34 +- .../app/shared/state/contributors.state.ts | 27 +- frontend/app/shared/state/queries.ts | 6 +- frontend/app/shared/state/query.ts | 43 ++- .../shared/state/rule-events.state.spec.ts | 27 +- .../app/shared/state/rule-events.state.ts | 15 +- frontend/app/shared/state/ui.state.ts | 2 +- frontend/package.json | 1 + frontend/tslint.json | 1 + 59 files changed, 1059 insertions(+), 390 deletions(-) create mode 100644 frontend/app/framework/angular/routers/router-2-state.spec.ts create mode 100644 frontend/app/framework/angular/routers/router-2-state.ts diff --git a/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetFoldersController.cs b/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetFoldersController.cs index 91ad76841..9bf731ae7 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetFoldersController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetFoldersController.cs @@ -51,14 +51,17 @@ namespace Squidex.Areas.Api.Controllers.Assets [ApiCosts(1)] public async Task 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); } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetFoldersDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetFoldersDto.cs index 7c8de65fb..97b0bb917 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetFoldersDto.cs +++ b/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 assetFolders, Resources resources) + /// + /// The path to the current folder. + /// + [Required] + public AssetFolderDto[] Path { get; set; } + + public static AssetFoldersDto FromAssets(IResultList assetFolders, IEnumerable 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); } diff --git a/frontend/app/features/administration/pages/users/user.component.html b/frontend/app/features/administration/pages/users/user.component.html index 10bbadc86..4580eceb9 100644 --- a/frontend/app/features/administration/pages/users/user.component.html +++ b/frontend/app/features/administration/pages/users/user.component.html @@ -1,4 +1,4 @@ - + diff --git a/frontend/app/features/administration/pages/users/users-page.component.ts b/frontend/app/features/administration/pages/users/users-page.component.ts index d996ea2a0..be99a19cb 100644 --- a/frontend/app/features/administration/pages/users/users-page.component.ts +++ b/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() { diff --git a/frontend/app/features/administration/state/users.state.spec.ts b/frontend/app/features/administration/state/users.state.spec.ts index 03fc78de6..1e47f0e05 100644 --- a/frontend/app/features/administration/state/users.state.spec.ts +++ b/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; - let localStore: IMock; let usersService: IMock; let usersState: UsersState; beforeEach(() => { dialogs = Mock.ofType(); - localStore = Mock.ofType(); - usersService = Mock.ofType(); - 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', () => { diff --git a/frontend/app/features/administration/state/users.state.ts b/frontend/app/features/administration/state/users.state.ts index cec2d910c..5155a19d2 100644 --- a/frontend/app/features/administration/state/users.state.ts +++ b/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 { 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 { 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 { 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 { 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); diff --git a/frontend/app/features/assets/pages/assets-page.component.ts b/frontend/app/features/assets/pages/assets-page.component.ts index 47cdd69db..e9985f3b4 100644 --- a/frontend/app/features/assets/pages/assets-page.component.ts +++ b/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() { diff --git a/frontend/app/features/content/pages/contents/contents-page.component.ts b/frontend/app/features/content/pages/contents/contents-page.component.ts index 47c393d04..a6d8a4818 100644 --- a/frontend/app/features/content/pages/contents/contents-page.component.ts +++ b/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); - } - } } \ No newline at end of file diff --git a/frontend/app/features/content/shared/forms/array-editor.component.ts b/frontend/app/features/content/shared/forms/array-editor.component.ts index c860663a8..30011e966 100644 --- a/frontend/app/features/content/shared/forms/array-editor.component.ts +++ b/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); diff --git a/frontend/app/features/content/shared/forms/array-item.component.ts b/frontend/app/features/content/shared/forms/array-item.component.ts index 24b61e525..6c03d58d2 100644 --- a/frontend/app/features/content/shared/forms/array-item.component.ts +++ b/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) { diff --git a/frontend/app/features/content/shared/list/content-list-cell.directive.ts b/frontend/app/features/content/shared/list/content-list-cell.directive.ts index 00bc89856..c3cb45572 100644 --- a/frontend/app/features/content/shared/list/content-list-cell.directive.ts +++ b/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) { let result = 0; - for (let field of fields) { + for (const field of fields) { result += getCellWidth(field); } diff --git a/frontend/app/features/rules/pages/events/rule-events-page.component.ts b/frontend/app/features/rules/pages/events/rule-events-page.component.ts index e30b7685b..82f52bb1b 100644 --- a/frontend/app/features/rules/pages/events/rule-events-page.component.ts +++ b/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() { diff --git a/frontend/app/features/settings/pages/contributors/contributors-page.component.html b/frontend/app/features/settings/pages/contributors/contributors-page.component.html index a98d561fc..77901540d 100644 --- a/frontend/app/features/settings/pages/contributors/contributors-page.component.html +++ b/frontend/app/features/settings/pages/contributors/contributors-page.component.html @@ -49,7 +49,7 @@
diff --git a/frontend/app/features/settings/pages/contributors/contributors-page.component.ts b/frontend/app/features/settings/pages/contributors/contributors-page.component.ts index e395ad3b6..ab53db9c6 100644 --- a/frontend/app/features/settings/pages/contributors/contributors-page.component.ts +++ b/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() { diff --git a/frontend/app/framework/angular/forms/editors/checkbox-group.component.ts b/frontend/app/framework/angular/forms/editors/checkbox-group.component.ts index 077d179eb..e4c5a71c2 100644 --- a/frontend/app/framework/angular/forms/editors/checkbox-group.component.ts +++ b/frontend/app/framework/angular/forms/editors/checkbox-group.component.ts @@ -105,7 +105,7 @@ export class CheckboxGroupComponent extends StatefulControlComponent= s || -value >= s) { - value /= s; + while (value >= factor || -value >= factor) { + value /= factor; u++; } diff --git a/frontend/app/framework/angular/routers/router-2-state.spec.ts b/frontend/app/framework/angular/routers/router-2-state.spec.ts new file mode 100644 index 000000000..5cdacba39 --- /dev/null +++ b/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; + + beforeEach(() => { + localStore = Mock.ofType(); + + 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; + let routerQueryParams: BehaviorSubject; + let routeActivated: any; + let router: IMock; + let router2State: Router2State; + let state: State; + let invoked = 0; + + beforeEach(() => { + localStore = Mock.ofType(); + + router = Mock.ofType(); + + state = new State({}); + + routerQueryParams = new BehaviorSubject({}); + 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' }); + }); + }); +}); \ No newline at end of file diff --git a/frontend/app/framework/angular/routers/router-2-state.ts b/frontend/app/framework/angular/routers/router-2-state.ts new file mode 100644 index 000000000..9dfbbe11c --- /dev/null +++ b/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(state: State): StateSynchronizerMap; +} + +export interface StateSynchronizerMap { + 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; + + constructor( + private readonly route: ActivatedRoute, + private readonly router: Router, + private readonly localStore: LocalStoreService + ) { + } + + public ngOnDestroy() { + this.mapper?.ngOnDestroy(); + } + + public mapTo(state: State): Router2StateMap { + this.mapper?.ngOnDestroy(); + this.mapper = this.mapper || new Router2StateMap(state, this.route, this.router, this.localStore); + + return this.mapper; + } +} + +export class Router2StateMap implements OnDestroy, StateSynchronizerMap { + 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, + 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 = {}; + + 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; + } +} \ No newline at end of file diff --git a/frontend/app/framework/angular/shortcut.component.spec.ts b/frontend/app/framework/angular/shortcut.component.spec.ts index 363f31007..85a5c20ed 100644 --- a/frontend/app/framework/angular/shortcut.component.spec.ts +++ b/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; } diff --git a/frontend/app/framework/declarations.ts b/frontend/app/framework/declarations.ts index 206268ce5..d4c2c23a8 100644 --- a/frontend/app/framework/declarations.ts +++ b/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'; diff --git a/frontend/app/framework/services/loading.service.spec.ts b/frontend/app/framework/services/loading.service.spec.ts index 6ddd3afb7..bbcb73fa0 100644 --- a/frontend/app/framework/services/loading.service.spec.ts +++ b/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(); + const events = new Subject(); it('should instantiate from factory', () => { const loadingService = LoadingServiceFactory({ events }); diff --git a/frontend/app/framework/state.ts b/frontend/app/framework/state.ts index 18a9b51bd..8e92f03e2 100644 --- a/frontend/app/framework/state.ts +++ b/frontend/app/framework/state.ts @@ -132,7 +132,7 @@ export class Model { for (const key in values) { if (values.hasOwnProperty(key)) { - let value = values[key]; + const value = values[key]; if (value || !validOnly) { clone[key] = value; diff --git a/frontend/app/framework/utils/modal-positioner.spec.ts b/frontend/app/framework/utils/modal-positioner.spec.ts index 68721fd6f..5ccd5c733 100644 --- a/frontend/app/framework/utils/modal-positioner.spec.ts +++ b/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}`, () => { diff --git a/frontend/app/framework/utils/pager.spec.ts b/frontend/app/framework/utils/pager.spec.ts index 382761b7b..96e2e7fc2 100644 --- a/frontend/app/framework/utils/pager.spec.ts +++ b/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(); - - 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(); - - const pager = new Pager(0, 0, 25); - - pager.saveTo('my', localStore.object); - - localStore.verify(x => x.setInt('my.pageSize', 25), Times.once()); - - expect().nothing(); - }); }); \ No newline at end of file diff --git a/frontend/app/framework/utils/pager.ts b/frontend/app/framework/utils/pager.ts index cb8fa0696..d751fa3cb 100644 --- a/frontend/app/framework/utils/pager.ts +++ b/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; diff --git a/frontend/app/framework/utils/tag-values.ts b/frontend/app/framework/utils/tag-values.ts index ff67333c2..bb070f2fb 100644 --- a/frontend/app/framework/utils/tag-values.ts +++ b/frontend/app/framework/utils/tag-values.ts @@ -124,7 +124,7 @@ export function getTagValues(values: ReadonlyArray) { } 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 { diff --git a/frontend/app/framework/utils/types.ts b/frontend/app/framework/utils/types.ts index 5d9d936d9..2c030f00d 100644 --- a/frontend/app/framework/utils/types.ts +++ b/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; diff --git a/frontend/app/shared/components/assets/assets-list.component.html b/frontend/app/shared/components/assets/assets-list.component.html index bfbc16352..8c4d13e93 100644 --- a/frontend/app/shared/components/assets/assets-list.component.html +++ b/frontend/app/shared/components/assets/assets-list.component.html @@ -30,7 +30,7 @@
+ (navigate)="state.navigate($event.id)"> @@ -46,7 +46,7 @@ cdkDrag [cdkDragData]="assetFolder" [cdkDragDisabled]="isDisabled || !assetFolder.canMove" - (navigate)="state.navigate($event)" + (navigate)="state.navigate($event.id)" (delete)="deleteAssetFolder($event)"> diff --git a/frontend/app/shared/components/assets/image-focus-point.component.ts b/frontend/app/shared/components/assets/image-focus-point.component.ts index 9da5da145..84a3e0381 100644 --- a/frontend/app/shared/components/assets/image-focus-point.component.ts +++ b/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); } } diff --git a/frontend/app/shared/components/search/search-form.component.ts b/frontend/app/shared/components/search/search-form.component.ts index a90f11985..61a29e94f 100644 --- a/frontend/app/shared/components/search/search-form.component.ts +++ b/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(); } diff --git a/frontend/app/shared/guards/must-be-authenticated.guard.spec.ts b/frontend/app/shared/guards/must-be-authenticated.guard.spec.ts index 47626719e..00de8b102 100644 --- a/frontend/app/shared/guards/must-be-authenticated.guard.spec.ts +++ b/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; let authService: IMock; - 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(); diff --git a/frontend/app/shared/guards/must-be-not-authenticated.guard.spec.ts b/frontend/app/shared/guards/must-be-not-authenticated.guard.spec.ts index c3a1c01fb..33abe1fd5 100644 --- a/frontend/app/shared/guards/must-be-not-authenticated.guard.spec.ts +++ b/frontend/app/shared/guards/must-be-not-authenticated.guard.spec.ts @@ -14,8 +14,8 @@ import { MustBeNotAuthenticatedGuard } from './must-be-not-authenticated.guard'; describe('MustBeNotAuthenticatedGuard', () => { let router: IMock; let authService: IMock; - 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(); diff --git a/frontend/app/shared/services/assets.service.spec.ts b/frontend/app/shared/services/assets.service.spec.ts index 974e67cd5..3fc4f021d 100644 --- a/frontend/app/shared/services/assets.service.spec.ts +++ b/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) ])); })); diff --git a/frontend/app/shared/services/assets.service.ts b/frontend/app/shared/services/assets.service.ts index f94bcc97d..a6e7007bb 100644 --- a/frontend/app/shared/services/assets.service.ts +++ b/frontend/app/shared/services/assets.service.ts @@ -85,6 +85,13 @@ export class AssetDto { } export class AssetFoldersDto extends ResultSet { + constructor(total: number, items: ReadonlyArray, + public readonly path: ReadonlyArray, + 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 { 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.')); } diff --git a/frontend/app/shared/services/autosave.service.ts b/frontend/app/shared/services/autosave.service.ts index 1945ae5bb..cab935bd1 100644 --- a/frontend/app/shared/services/autosave.service.ts +++ b/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 = ''; diff --git a/frontend/app/shared/services/contents.service.ts b/frontend/app/shared/services/contents.service.ts index 056826c87..541eabe38 100644 --- a/frontend/app/shared/services/contents.service.ts +++ b/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 = {}; diff --git a/frontend/app/shared/services/rules.service.ts b/frontend/app/shared/services/rules.service.ts index 38f287015..57ff07ded 100644 --- a/frontend/app/shared/services/rules.service.ts +++ b/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) => diff --git a/frontend/app/shared/services/schemas.service.ts b/frontend/app/shared/services/schemas.service.ts index 296f6dc3d..3249ce16c 100644 --- a/frontend/app/shared/services/schemas.service.ts +++ b/frontend/app/shared/services/schemas.service.ts @@ -208,9 +208,9 @@ export class SchemaDetailsDto extends SchemaDto { } function findFields(names: ReadonlyArray, fields: ReadonlyArray): 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 { diff --git a/frontend/app/shared/services/usages.service.ts b/frontend/app/shared/services/usages.service.ts index 91af778a0..a04407ccc 100644 --- a/frontend/app/shared/services/usages.service.ts +++ b/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), diff --git a/frontend/app/shared/services/workflows.service.ts b/frontend/app/shared/services/workflows.service.ts index 8ebdeb04c..1f710f3ce 100644 --- a/frontend/app/shared/services/workflows.service.ts +++ b/frontend/app/shared/services/workflows.service.ts @@ -142,7 +142,7 @@ export class WorkflowDto extends Model { 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 { 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]; diff --git a/frontend/app/shared/state/_test-helpers.ts b/frontend/app/shared/state/_test-helpers.ts index 4d9cf0dc0..2cc0fcd57 100644 --- a/frontend/app/shared/state/_test-helpers.ts +++ b/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.setup(x => x.user) .returns(() => { id: modifier, token: modifier }); +class DummySynchronizer implements StateSynchronizer, StateSynchronizerMap { + constructor( + private readonly subject: Subject + ) { + } + + public build() { + return; + } + + public mapTo(): StateSynchronizerMap { + 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(); + + const synchronizer = new DummySynchronizer(subject); + + const trigger = () => { + subject.next(); + }; + + return { synchronizer, trigger }; +} + export const TestValues = { app, appsState, authService, + buildDummyStateSynchronizer, creation, creator, modified, diff --git a/frontend/app/shared/state/asset-uploader.state.spec.ts b/frontend/app/shared/state/asset-uploader.state.spec.ts index ae42f604e..62f7109e6 100644 --- a/frontend/app/shared/state/asset-uploader.state.spec.ts +++ b/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 = { 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(); diff --git a/frontend/app/shared/state/assets.forms.ts b/frontend/app/shared/state/assets.forms.ts index b8943f0c8..68f5b481b 100644 --- a/frontend/app/shared/state/assets.forms.ts +++ b/frontend/app/shared/state/assets.forms.ts @@ -62,7 +62,7 @@ export class AnnotateAssetForm extends Form { const { app, appsState, + buildDummyStateSynchronizer, newVersion } = TestValues; @@ -30,20 +31,15 @@ describe('AssetsState', () => { let dialogs: IMock; let assetsService: IMock; let assetsState: AssetsState; - let localStore: IMock; beforeEach(() => { dialogs = Mock.ofType(); - localStore = Mock.ofType(); - localStore.setup(x => x.getInt('assets.pageSize', 30)) - .returns(() => 30); - assetsService = Mock.ofType(); 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; - - 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; + 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(); diff --git a/frontend/app/shared/state/assets.state.ts b/frontend/app/shared/state/assets.state.ts index d3ff13fd3..40db2cd5c 100644 --- a/frontend/app/shared/state/assets.state.ts +++ b/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 } = { canCreate: false, items: [] }; +const EMPTY_FOLDERS: { canCreate: boolean, items: ReadonlyArray, path?: ReadonlyArray } = { canCreate: false, items: [] }; -const ROOT_PATH: ReadonlyArray = [{ 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; // 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 { 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 { 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 { @@ -150,30 +156,32 @@ export class AssetsState extends State { 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 { 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 { 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 { } 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 { } 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 { 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 { 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 { 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 { return this.searchInternal(null, {}); } @@ -414,7 +408,7 @@ export class AssetsState extends State { } 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) { return path.length > 1 ? { folderName: '', id: path[path.length - 2].id } : undefined; } diff --git a/frontend/app/shared/state/contents.forms.spec.ts b/frontend/app/shared/state/contents.forms.spec.ts index e3d7673fa..799297d59 100644 --- a/frontend/app/shared/state/contents.forms.spec.ts +++ b/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]; diff --git a/frontend/app/shared/state/contents.state.ts b/frontend/app/shared/state/contents.state.ts index 25210c56e..12d0e1efa 100644 --- a/frontend/app/shared/state/contents.state.ts +++ b/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 { - private previousId: string; - public selectedContent: Observable = this.project(x => x.selectedContent, Types.equals); @@ -84,16 +82,11 @@ export abstract class ContentsStateBase extends State { 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 { })); } - public load(isReload = false): Observable { - 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 { + if (!isReload) { + this.resetState({ selectedContent: this.snapshot.selectedContent }); } return this.loadInternal(isReload); @@ -150,8 +150,6 @@ export abstract class ContentsStateBase extends State { 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 { @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() { diff --git a/frontend/app/shared/state/contributors.state.spec.ts b/frontend/app/shared/state/contributors.state.spec.ts index f6c27695c..0554632cc 100644 --- a/frontend/app/shared/state/contributors.state.spec.ts +++ b/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; let contributorsService: IMock; let contributorsState: ContributorsState; - let localStore: IMock; beforeEach(() => { dialogs = Mock.ofType(); - localStore = Mock.ofType(); - contributorsService = Mock.ofType(); 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(); diff --git a/frontend/app/shared/state/contributors.state.ts b/frontend/app/shared/state/contributors.state.ts index a4eb90d6c..aa797c309 100644 --- a/frontend/app/shared/state/contributors.state.ts +++ b/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 { 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 { 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 { - if (isReload) { + if (!isReload) { const contributorsPager = this.snapshot.contributorsPager.reset(); this.resetState({ contributorsPager }); @@ -125,7 +125,7 @@ export class ContributorsState extends State { } public search(query: string) { - this.next(s => ({ ...s, query, queryRegex: new RegExp(query, 'i') })); + this.next(s => ({ ...s, query })); } public revoke(contributor: ContributorDto): Observable { @@ -158,6 +158,7 @@ export class ContributorsState extends State { const contributorsPager = s.contributorsPager.setCount(contributors.length); return { + ...s, canCreate, contributors, contributorsPager, diff --git a/frontend/app/shared/state/queries.ts b/frontend/app/shared/state/queries.ts index fa115fe1a..baee8bea7 100644 --- a/frontend/app/shared/state/queries.ts +++ b/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 }; } \ No newline at end of file diff --git a/frontend/app/shared/state/query.ts b/frontend/app/shared/state/query.ts index b8c93f2aa..8c49279c9 100644 --- a/frontend/app/shared/state/query.ts +++ b/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 { diff --git a/frontend/app/shared/state/rule-events.state.spec.ts b/frontend/app/shared/state/rule-events.state.spec.ts index a7b6ca496..7333d3e1f 100644 --- a/frontend/app/shared/state/rule-events.state.spec.ts +++ b/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; let rulesService: IMock; let ruleEventsState: RuleEventsState; - let localStore: IMock; beforeEach(() => { dialogs = Mock.ofType(); - localStore = Mock.ofType(); - rulesService = Mock.ofType(); 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, []))); diff --git a/frontend/app/shared/state/rule-events.state.ts b/frontend/app/shared/state/rule-events.state.ts index 030e0527f..030d447bc 100644 --- a/frontend/app/shared/state/rule-events.state.ts +++ b/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 { 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 { diff --git a/frontend/app/shared/state/ui.state.ts b/frontend/app/shared/state/ui.state.ts index b1c73b697..9549bb75b 100644 --- a/frontend/app/shared/state/ui.state.ts +++ b/frontend/app/shared/state/ui.state.ts @@ -209,7 +209,7 @@ export class UIState extends State { let current = setting; for (const segment of segments) { - let temp = current[segment]; + const temp = current[segment]; if (temp) { current[segment] = temp; diff --git a/frontend/package.json b/frontend/package.json index bf2e3c98a..fa513db7d 100644 --- a/frontend/package.json +++ b/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", diff --git a/frontend/tslint.json b/frontend/tslint.json index 88fde81a3..74e273c52 100644 --- a/frontend/tslint.json +++ b/frontend/tslint.json @@ -92,6 +92,7 @@ true, "sqx" ], + "prefer-const": true, "prefer-for-of": true, "quotemark": [ true,