Browse Source

Merge branch 'master' of github.com:Squidex/squidex

pull/613/head
Sebastian 5 years ago
parent
commit
0b753bee42
  1. 5
      frontend/app/features/administration/pages/cluster/cluster-page.component.ts
  2. 2
      frontend/app/features/administration/pages/event-consumers/event-consumer.component.ts
  3. 4
      frontend/app/features/administration/pages/users/users-page.component.html
  4. 11
      frontend/app/features/administration/pages/users/users-page.component.ts
  5. 17
      frontend/app/features/administration/state/users.state.spec.ts
  6. 15
      frontend/app/features/administration/state/users.state.ts
  7. 4
      frontend/app/features/api/pages/graphql/graphql-page.component.ts
  8. 8
      frontend/app/features/assets/pages/assets-filters-page.component.ts
  9. 13
      frontend/app/features/assets/pages/assets-page.component.ts
  10. 36
      frontend/app/features/content/pages/content/content-history-page.component.ts
  11. 44
      frontend/app/features/content/pages/content/content-page.component.html
  12. 33
      frontend/app/features/content/pages/content/content-page.component.ts
  13. 20
      frontend/app/features/content/pages/content/references/content-references.component.ts
  14. 48
      frontend/app/features/content/pages/contents/contents-filters-page.component.html
  15. 22
      frontend/app/features/content/pages/contents/contents-filters-page.component.ts
  16. 15
      frontend/app/features/content/pages/contents/contents-page.component.html
  17. 123
      frontend/app/features/content/pages/contents/contents-page.component.ts
  18. 4
      frontend/app/features/content/pages/sidebar/sidebar-page.component.ts
  19. 1
      frontend/app/features/content/shared/forms/field-editor.component.html
  20. 2
      frontend/app/features/content/shared/list/content.component.html
  21. 2
      frontend/app/features/content/shared/list/content.component.ts
  22. 6
      frontend/app/features/content/shared/references/content-creator.component.html
  23. 24
      frontend/app/features/content/shared/references/content-creator.component.ts
  24. 8
      frontend/app/features/content/shared/references/content-selector.component.html
  25. 6
      frontend/app/features/content/shared/references/content-selector.component.ts
  26. 1
      frontend/app/features/content/shared/references/references-editor.component.html
  27. 3
      frontend/app/features/content/shared/references/references-editor.component.ts
  28. 16
      frontend/app/features/dashboard/pages/dashboard-page.component.html
  29. 22
      frontend/app/features/dashboard/pages/dashboard-page.component.ts
  30. 9
      frontend/app/features/rules/pages/events/rule-events-page.component.ts
  31. 2
      frontend/app/features/schemas/pages/schema/fields/field-wizard.component.html
  32. 12
      frontend/app/features/schemas/pages/schema/fields/forms/field-form.component.html
  33. 2
      frontend/app/features/schemas/pages/schema/fields/schema-fields.component.html
  34. 44
      frontend/app/features/schemas/pages/schema/schema-page.component.html
  35. 21
      frontend/app/features/schemas/pages/schema/schema-page.component.ts
  36. 4
      frontend/app/features/schemas/pages/schema/scripts/schema-scripts-form.component.html
  37. 14
      frontend/app/features/schemas/pages/schema/ui/schema-ui-form.component.html
  38. 10
      frontend/app/features/schemas/pages/schema/ui/schema-ui-form.component.ts
  39. 9
      frontend/app/features/settings/pages/contributors/contributors-page.component.ts
  40. 4
      frontend/app/features/settings/pages/more/more-page.component.ts
  41. 8
      frontend/app/features/settings/pages/workflows/workflow.component.html
  42. 2
      frontend/app/features/settings/settings-area.component.html
  43. 6
      frontend/app/features/settings/settings-area.component.ts
  44. 4
      frontend/app/framework/angular/language-selector.component.ts
  45. 6
      frontend/app/framework/angular/routers/can-deactivate.guard.ts
  46. 288
      frontend/app/framework/angular/routers/router-2-state.spec.ts
  47. 259
      frontend/app/framework/angular/routers/router-2-state.ts
  48. 87
      frontend/app/shared/components/assets/asset-dialog.component.html
  49. 33
      frontend/app/shared/components/assets/asset-dialog.component.ts
  50. 2
      frontend/app/shared/components/assets/asset-uploader.component.html
  51. 3
      frontend/app/shared/components/search/queries/filter-comparison.component.html
  52. 5
      frontend/app/shared/components/search/search-form.component.html
  53. 2
      frontend/app/shared/guards/schema-must-not-be-singleton.guard.ts
  54. 60
      frontend/app/shared/state/_test-helpers.ts
  55. 11
      frontend/app/shared/state/apps.state.ts
  56. 15
      frontend/app/shared/state/assets.state.spec.ts
  57. 18
      frontend/app/shared/state/assets.state.ts
  58. 25
      frontend/app/shared/state/contents.state.ts
  59. 14
      frontend/app/shared/state/contributors.state.spec.ts
  60. 14
      frontend/app/shared/state/contributors.state.ts
  61. 9
      frontend/app/shared/state/languages.state.ts
  62. 80
      frontend/app/shared/state/query.spec.ts
  63. 31
      frontend/app/shared/state/query.ts
  64. 20
      frontend/app/shared/state/rule-events.state.spec.ts
  65. 16
      frontend/app/shared/state/rule-events.state.ts
  66. 11
      frontend/app/shared/state/schemas.state.ts
  67. 9
      frontend/app/shared/state/ui.state.ts
  68. 20
      frontend/app/shell/pages/app/app-area.component.html
  69. 6
      frontend/app/shell/pages/app/app-area.component.ts
  70. 2
      frontend/app/shell/pages/app/left-menu.component.html
  71. 10
      frontend/app/shell/pages/app/left-menu.component.ts
  72. 4
      frontend/app/shell/pages/internal/apps-menu.component.html
  73. 1
      frontend/app/shell/pages/internal/profile-menu.component.ts
  74. 2
      frontend/app/shell/pages/internal/search-menu.component.html
  75. 2
      frontend/app/shell/pages/internal/search-menu.component.ts

5
frontend/app/features/administration/pages/cluster/cluster-page.component.ts

@ -6,7 +6,6 @@
*/
import { Component } from '@angular/core';
import { UIState } from '@app/shared';
@Component({
selector: 'sqx-cluster-area',
@ -14,8 +13,4 @@ import { UIState } from '@app/shared';
templateUrl: './cluster-page.component.html'
})
export class ClusterPageComponent {
constructor(
public readonly uiState: UIState
) {
}
}

2
frontend/app/features/administration/pages/event-consumers/event-consumer.component.ts

@ -24,7 +24,7 @@ export class EventConsumerComponent {
public eventConsumer: EventConsumerDto;
constructor(
public readonly eventConsumersState: EventConsumersState
private readonly eventConsumersState: EventConsumersState
) {
}

4
frontend/app/features/administration/pages/users/users-page.component.html

@ -51,9 +51,7 @@
<div content>
<table class="table table-items table-fixed" *ngIf="usersState.users | async; let users" [sqxSyncWidth]="header">
<tbody *ngFor="let user of users; trackBy: trackByUser"
[sqxUser]="user">
</tbody>
<tbody *ngFor="let user of users; trackBy: trackByUser" [sqxUser]="user"></tbody>
</table>
</div>

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

@ -33,7 +33,14 @@ export class UsersPageComponent extends ResourceOwner implements OnInit {
}
public ngOnInit() {
this.usersState.loadAndListen(this.usersRoute);
const initial =
this.usersRoute.mapTo(this.usersState)
.withPaging('users', 10)
.withString('query')
.getInitial();
this.usersState.load(false, initial);
this.usersRoute.listen();
}
public reload() {
@ -44,7 +51,7 @@ export class UsersPageComponent extends ResourceOwner implements OnInit {
this.usersState.search(this.usersFilter.value);
}
public trackByUser(_ndex: number, user: UserDto) {
public trackByUser(_index: number, user: UserDto) {
return user.id;
}
}

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

@ -10,13 +10,10 @@ import { DialogService } 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);
@ -110,20 +107,6 @@ describe('UsersState', () => {
expect(usersState.snapshot.query).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', () => {

15
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, getPagingInfo, ListState, shareSubscribed, State, StateSynchronizer } from '@app/shared';
import { DialogService, getPagingInfo, ListState, shareSubscribed, State } from '@app/shared';
import { Observable, of } from 'rxjs';
import { catchError, finalize, tap } from 'rxjs/operators';
import { CreateUserDto, UpdateUserDto, UserDto, UsersService } from './../services/users.service';
@ -83,18 +83,9 @@ export class UsersState extends State<Snapshot> {
return this.usersService.getUser(id).pipe(catchError(() => of(null)));
}
public loadAndListen(synchronizer: StateSynchronizer) {
synchronizer.mapTo(this)
.keep('selectedUser')
.withPaging('users', 10)
.withString('query', 'q')
.whenSynced(() => this.loadInternal(false))
.build();
}
public load(isReload = false): Observable<any> {
public load(isReload = false, update: Partial<Snapshot> = {}): Observable<any> {
if (!isReload) {
this.resetState({ selectedUser: this.snapshot.selectedUser });
this.resetState({ selectedUser: this.snapshot.selectedUser, ...update });
}
return this.loadInternal(isReload);

4
frontend/app/features/api/pages/graphql/graphql-page.component.ts

@ -40,6 +40,8 @@ export class GraphQLPageComponent implements AfterViewInit {
}
private request(params: any) {
return this.graphQlService.query(this.appsState.appName, params).pipe(catchError(response => of(response.error))).toPromise();
return this.graphQlService.query(this.appsState.appName, params).pipe(
catchError(response => of(response.error)))
.toPromise();
}
}

8
frontend/app/features/assets/pages/assets-filters-page.component.ts

@ -14,12 +14,12 @@ import { AssetsState, Queries, Query, UIState } from '@app/shared';
templateUrl: './assets-filters-page.component.html'
})
export class AssetsFiltersPageComponent {
public assetsQueries = new Queries(this.uiState, 'assets');
public assetsQueries: Queries;
constructor(
public readonly assetsState: AssetsState,
private readonly uiState: UIState
constructor(uiState: UIState,
public readonly assetsState: AssetsState
) {
this.assetsQueries = new Queries(uiState, 'assets');
}
public search(query: Query) {

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

@ -6,7 +6,7 @@
*/
import { Component, OnInit } from '@angular/core';
import { AssetsState, DialogModel, LocalStoreService, Queries, Query, ResourceOwner, Router2State, UIState } from '@app/shared';
import { AssetsState, DialogModel, LocalStoreService, Queries, Query, QueryFullTextSynchronizer, ResourceOwner, Router2State, UIState } from '@app/shared';
import { Settings } from '@app/shared/state/settings';
@Component({
@ -36,7 +36,16 @@ export class AssetsPageComponent extends ResourceOwner implements OnInit {
}
public ngOnInit() {
this.assetsState.loadAndListen(this.assetsRoute);
const initial =
this.assetsRoute.mapTo(this.assetsState)
.withPaging('assets', 30)
.withString('parentId')
.withStrings('tagsSelected')
.withSynchronizer(QueryFullTextSynchronizer.INSTANCE)
.getInitial();
this.assetsState.load(false, initial);
this.assetsRoute.listen();
}
public reload() {

36
frontend/app/features/content/pages/content/content-history-page.component.ts

@ -8,9 +8,9 @@
// tslint:disable: triple-equals
import { Component, OnInit, ViewChild } from '@angular/core';
import { AppsState, ContentDto, ContentsState, fadeAnimation, HistoryEventDto, HistoryService, ModalModel, ResourceOwner, SchemaDetailsDto, SchemasState, switchSafe } from '@app/shared';
import { AppsState, ContentDto, ContentsState, defined, fadeAnimation, HistoryEventDto, HistoryService, ModalModel, ResourceOwner, SchemasState, switchSafe } from '@app/shared';
import { Observable, timer } from 'rxjs';
import { filter, map, onErrorResumeNext, switchMap } from 'rxjs/operators';
import { map } from 'rxjs/operators';
import { DueTimeSelectorComponent } from './../../shared/due-time-selector.component';
import { ContentPageComponent } from './content-page.component';
@ -26,8 +26,6 @@ export class ContentHistoryPageComponent extends ResourceOwner implements OnInit
@ViewChild('dueTimeSelector', { static: false })
public dueTimeSelector: DueTimeSelectorComponent;
public schema: SchemaDetailsDto;
public content: ContentDto;
public contentEvents: Observable<ReadonlyArray<HistoryEventDto>>;
@ -46,43 +44,31 @@ export class ContentHistoryPageComponent extends ResourceOwner implements OnInit
public ngOnInit() {
this.own(
this.schemasState.selectedSchema
.subscribe(schema => {
if (schema) {
this.schema = schema;
}
}));
this.own(
this.contentsState.selectedContent
this.contentsState.selectedContent.pipe(defined())
.subscribe(content => {
if (content) {
this.content = content;
}
this.content = content;
}));
this.contentEvents =
this.contentsState.selectedContent.pipe(
filter(x => !!x),
map(content => `schemas.${this.schemasState.schemaId}.contents.${content?.id}`),
defined(),
map(content => `schemas.${this.schemasState.schemaId}.contents.${content.id}`),
switchSafe(channel => timer(0, 5000).pipe(map(() => channel))),
switchSafe(channel => this.historyService.getHistory(this.appsState.appName, channel)));
}
public changeStatus(status: string) {
this.contentPage.checkPendingChangesBeforeChangingStatus().pipe(
filter(x => !!x),
switchMap(_ => this.dueTimeSelector.selectDueTime(status)),
switchMap(d => this.contentsState.changeManyStatus([this.content], status, d)),
onErrorResumeNext())
defined(),
switchSafe(_ => this.dueTimeSelector.selectDueTime(status)),
switchSafe(d => this.contentsState.changeManyStatus([this.content], status, d)))
.subscribe();
}
public createDraft() {
this.contentPage.checkPendingChangesBeforeChangingStatus().pipe(
filter(x => !!x),
switchMap(d => this.contentsState.createDraft(this.content)),
onErrorResumeNext())
defined(),
switchSafe(() => this.contentsState.createDraft(this.content)))
.subscribe();
}

44
frontend/app/features/content/pages/content/content-page.component.html

@ -18,9 +18,21 @@
<ng-container *ngIf="content">
<sqx-title message="i18n:contents.editPageTitle"></sqx-title>
<ul class="nav nav-tabs2">
<li class="nav-item" *ngFor="let tab of selectableTabs">
<a class="nav-link" [class.active]="selectedTab === tab" (click)="selectTab(tab)">{{tab | sqxTranslate}}</a>
<ul class="nav nav-tabs2" *ngIf="contentTab | async; let tab">
<li class="nav-item">
<a class="nav-link" routerLink="./" [queryParams]="{ tab: 'editor' }" [class.active]="tab === 'editor'">
{{ 'i18n:contents.contentTab.editor' | sqxTranslate }}
</a>
</li>
<li>
<a class="nav-link" routerLink="./" [queryParams]="{ tab: 'references' }" [class.active]="tab === 'references'">
{{ 'i18n:contents.contentTab.references' | sqxTranslate }}
</a>
</li>
<li>
<a class="nav-link" routerLink="./" [queryParams]="{ tab: 'referencing' }" [class.active]="tab === 'referencing'">
{{ 'i18n:contents.contentTab.referencing' | sqxTranslate }}
</a>
</li>
</ul>
</ng-container>
@ -28,7 +40,7 @@
<ng-container menu>
<ng-container *ngIf="content; else noContent">
<ng-container *ngIf="selectedTab === 'i18n:contents.contentTab.editor'; else referenceHeader">
<ng-container *ngIf="(contentTab | async) === 'fields'; else referenceHeader">
<sqx-notifo topic="apps/{{contentsState.appId}}/schemas/{{schema?.name}}/contents/{{content.id}}"></sqx-notifo>
<sqx-preview-button [schema]="schema" [content]="content"></sqx-preview-button>
@ -88,8 +100,18 @@
<ng-container content>
<ng-container *ngIf="content; else noContentEditor">
<ng-container [ngSwitch]="selectedTab">
<ng-container *ngSwitchCase="'i18n:contents.contentTab.editor'">
<ng-container [ngSwitch]="contentTab | async">
<ng-container *ngSwitchCase="'references'">
<sqx-content-references mode="references"
[content]="content">
</sqx-content-references>
</ng-container>
<ng-container *ngSwitchCase="'referencing'">
<sqx-content-references mode="referencing"
[content]="content">
</sqx-content-references>
</ng-container>
<ng-container *ngSwitchDefault>
<sqx-content-editor
[(language)]="language"
[contentForm]="contentForm"
@ -99,16 +121,6 @@
[schema]="schema">
</sqx-content-editor>
</ng-container>
<ng-container *ngSwitchCase="'i18n:contents.contentTab.references'">
<sqx-content-references mode="references"
[content]="content">
</sqx-content-references>
</ng-container>
<ng-container *ngSwitchCase="'i18n:contents.contentTab.referencing'">
<sqx-content-references mode="referencing"
[content]="content">
</sqx-content-references>
</ng-container>
</ng-container>
</ng-container>

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

@ -9,17 +9,11 @@
import { Component, OnInit, ViewChild } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { ApiUrlConfig, AppLanguageDto, AppsState, AuthService, AutoSaveKey, AutoSaveService, CanComponentDeactivate, ContentDto, ContentsState, DialogService, EditContentForm, fadeAnimation, LanguagesState, ModalModel, ResourceOwner, SchemaDetailsDto, SchemasState, TempService, Version } from '@app/shared';
import { ApiUrlConfig, AppLanguageDto, AppsState, AuthService, AutoSaveKey, AutoSaveService, CanComponentDeactivate, ContentDto, ContentsState, defined, DialogService, EditContentForm, fadeAnimation, LanguagesState, ModalModel, ResourceOwner, SchemaDetailsDto, SchemasState, TempService, Version } from '@app/shared';
import { Observable, of } from 'rxjs';
import { filter, tap } from 'rxjs/operators';
import { filter, map, tap } from 'rxjs/operators';
import { ContentReferencesComponent } from './references/content-references.component';
const TABS: ReadonlyArray<string> = [
'i18n:contents.contentTab.editor',
'i18n:contents.contentTab.references',
'i18n:contents.contentTab.referencing'
];
@Component({
selector: 'sqx-content-page',
styleUrls: ['./content-page.component.scss'],
@ -39,14 +33,12 @@ export class ContentPageComponent extends ResourceOwner implements CanComponentD
public formContext: any;
public contentTab = this.route.queryParams.pipe(map(x => x['tab'] || 'editor'));
public content?: ContentDto | null;
public contentVersion: Version | null;
public contentForm: EditContentForm;
public contentFormCompare: EditContentForm | null = null;
public selectableTabs = TABS;
public selectedTab = this.selectableTabs[0];
public dropdown = new ModalModel();
public language: AppLanguageDto;
@ -66,8 +58,8 @@ export class ContentPageComponent extends ResourceOwner implements CanComponentD
this.formContext = {
apiUrl: apiUrl.buildUrl('api'),
appId: appsState.snapshot.selectedApp!.id,
appName: appsState.snapshot.selectedApp!.name,
appId: contentsState.appId,
appName: appsState.appName,
user: authService.user
};
}
@ -76,14 +68,19 @@ export class ContentPageComponent extends ResourceOwner implements CanComponentD
this.contentsState.loadIfNotLoaded();
this.own(
this.languagesState.languagesDtos
this.languagesState.isoMasterLanguage
.subscribe(language => {
this.language = language;
}));
this.own(
this.languagesState.isoLanguages
.subscribe(languages => {
this.languages = languages;
this.language = this.languages.find(x => x.isMaster)!;
}));
this.own(
this.schemasState.selectedSchema
this.schemasState.selectedSchema.pipe(defined())
.subscribe(schema => {
this.schema = schema;
@ -139,10 +136,6 @@ export class ContentPageComponent extends ResourceOwner implements CanComponentD
);
}
public selectTab(tab: string) {
this.selectedTab = tab;
}
public validate() {
this.references?.validate();
}

20
frontend/app/features/content/pages/content/references/content-references.component.ts

@ -5,15 +5,16 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
import { AppLanguageDto, ContentDto, ManualContentsState } from '@app/shared';
import { ChangeDetectionStrategy, Component, Input, OnChanges, SimpleChanges } from '@angular/core';
import { AppLanguageDto, ContentDto, ManualContentsState, QuerySynchronizer, Router2State } from '@app/shared';
@Component({
selector: 'sqx-content-references',
styleUrls: ['./content-references.component.scss'],
templateUrl: './content-references.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
ManualContentsState
Router2State, ManualContentsState
]
})
export class ContentReferencesComponent implements OnChanges {
@ -27,6 +28,7 @@ export class ContentReferencesComponent implements OnChanges {
public mode: 'references' | 'referencing' = 'references';
constructor(
public readonly contentsRoute: Router2State,
public readonly contentsState: ManualContentsState
) {
}
@ -35,11 +37,19 @@ export class ContentReferencesComponent implements OnChanges {
if (changes['content'] || changes['mode']) {
this.contentsState.schema = { name: this.content.schemaName };
const initial =
this.contentsRoute.mapTo(this.contentsState)
.withPaging('contents', 10)
.withSynchronizer(QuerySynchronizer.INSTANCE)
.getInitial();
if (this.mode === 'references') {
this.contentsState.loadReference(this.content.id);
this.contentsState.loadReference(this.content.id, initial);
} else {
this.contentsState.loadReferencing(this.content.id);
this.contentsState.loadReferencing(this.content.id, initial);
}
this.contentsRoute.listen();
}
}

48
frontend/app/features/content/pages/contents/contents-filters-page.component.html

@ -4,33 +4,35 @@
</ng-container>
<ng-container content>
<sqx-query-list
[types]="'common.contents' | sqxTranslate"
[queryUsed]="contentsState.query | async"
[queries]="schemaQueries.defaultQueries"
(search)="search($event)">
</sqx-query-list>
<hr>
<div class="sidebar-section">
<h3>{{ 'contents.statusQueries' | sqxTranslate }}</h3>
<ng-container *ngIf="schemaQueries | async; let queries">
<sqx-query-list
[types]="'common.contents' | sqxTranslate"
[queryUsed]="contentsState.query | async"
[queries]="contentsState.statusQueries | async"
[queries]="queries.defaultQueries"
(search)="search($event)">
</sqx-query-list>
</div>
<hr>
<sqx-shared-queries
[types]="'common.contents' | sqxTranslate"
[queryUsed]="contentsState.query | async"
[queries]="schemaQueries"
(search)="search($event)">
</sqx-shared-queries>
<hr>
<div class="sidebar-section">
<h3>{{ 'contents.statusQueries' | sqxTranslate }}</h3>
<sqx-query-list
[types]="'common.contents' | sqxTranslate"
[queryUsed]="contentsState.query | async"
[queries]="contentsState.statusQueries | async"
(search)="search($event)">
</sqx-query-list>
</div>
<hr>
<sqx-shared-queries
[types]="'common.contents' | sqxTranslate"
[queryUsed]="contentsState.query | async"
[queries]="queries"
(search)="search($event)">
</sqx-shared-queries>
</ng-container>
</ng-container>
</sqx-panel>

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

@ -5,31 +5,27 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Component, OnInit } from '@angular/core';
import { ContentsState, Queries, Query, ResourceOwner, SchemasState, UIState } from '@app/shared';
import { Component } from '@angular/core';
import { ContentsState, defined, Queries, Query, SchemasState, UIState } from '@app/shared';
import { map } from 'rxjs/operators';
@Component({
selector: 'sqx-contents-filters-page',
styleUrls: ['./contents-filters-page.component.scss'],
templateUrl: './contents-filters-page.component.html'
})
export class ContentsFiltersPageComponent extends ResourceOwner implements OnInit {
public schemaQueries: Queries;
export class ContentsFiltersPageComponent {
public schemaQueries =
this.schemasState.selectedSchema.pipe(
defined(),
map(schema => new Queries(this.uiState, `schemas.${schema.name}`)
));
constructor(
public readonly contentsState: ContentsState,
private readonly schemasState: SchemasState,
private readonly uiState: UIState
) {
super();
}
public ngOnInit() {
this.own(
this.schemasState.selectedSchema
.subscribe(schema => {
this.schemaQueries = new Queries(this.uiState, `schemas.${schema.name}`);
}));
}
public search(query: Query) {

15
frontend/app/features/content/pages/contents/contents-page.component.html

@ -20,13 +20,14 @@
<sqx-search-form formClass="form" placeholder="{{ 'contents.searchPlaceholder' | sqxTranslate }}"
(queryChange)="search($event)"
[query]="contentsState.query | async"
[queries]="queries"
[queryModel]="queryModel"
[language]="languageMaster" enableShortcut="true">
[queries]="queries | async"
[queryModel]="queryModel | async"
[language]="languagesState.isoMasterLanguage | async"
enableShortcut="true">
</sqx-search-form>
</div>
<div class="col-auto pl-1" *ngIf="languages.length > 1">
<sqx-language-selector class="languages-buttons" (selectedLanguageChange)="selectLanguage($event)" [languages]="languages"></sqx-language-selector>
<sqx-language-selector class="languages-buttons" [(selectedLanguage)]="language" [languages]="languages"></sqx-language-selector>
</div>
<div class="col-auto pl-1">
<button type="button" class="btn btn-success" #newButton routerLink="new" title="i18n:contents.createContentTooltip" [disabled]="(contentsState.canCreateAny | async) === false">
@ -45,10 +46,10 @@
<div class="selection" *ngIf="selectionCount > 0">
{{ 'contents.selectionCount' | sqxTranslate: { count: selectionCount } }}&nbsp;&nbsp;
<button type="button" class="btn btn-outline-secondary btn-status mr-1" *ngFor="let status of nextStatuses | sqxKeys" (click)="changeSelectedStatus(status)">
<button type="button" class="btn btn-outline-secondary btn-status mr-1" *ngFor="let status of selectionStatuses | sqxKeys" (click)="changeSelectedStatus(status)">
<sqx-content-status layout="text"
[status]="status"
[statusColor]="nextStatuses[status]">
[statusColor]="selectionStatuses[status]">
</sqx-content-status>
</button>
@ -115,8 +116,8 @@
[selected]="isItemSelected(content)"
(selectedChange)="selectItem(content, $event)"
(statusChange)="changeStatus(content, $event)"
[cloneable]="contentsState.snapshot.canCreate"
(clone)="clone(content)"
[canClone]="contentsState.snapshot.canCreate"
[language]="language"
[link]="[content.id, 'history']"
[listFields]="listFields">

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

@ -9,9 +9,9 @@
import { Component, OnInit, ViewChild } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { AppLanguageDto, AppsState, ContentDto, ContentsState, ContributorsState, fadeAnimation, LanguagesState, ModalModel, Queries, Query, QueryModel, queryModelFromSchema, ResourceOwner, Router2State, SchemaDetailsDto, SchemasState, TableFields, TempService, UIState } from '@app/shared';
import { AppLanguageDto, AppsState, ContentDto, ContentsState, ContributorsState, defined, fadeAnimation, LanguagesState, ModalModel, Queries, Query, queryModelFromSchema, QuerySynchronizer, ResourceOwner, Router2State, SchemaDetailsDto, SchemasState, switchSafe, TableFields, TempService, UIState } from '@app/shared';
import { combineLatest } from 'rxjs';
import { distinctUntilChanged, onErrorResumeNext, switchMap, tap } from 'rxjs/operators';
import { distinctUntilChanged, map, switchMap, take, tap } from 'rxjs/operators';
import { DueTimeSelectorComponent } from './../../shared/due-time-selector.component';
@Component({
@ -26,6 +26,9 @@ import { DueTimeSelectorComponent } from './../../shared/due-time-selector.compo
]
})
export class ContentsPageComponent extends ResourceOwner implements OnInit {
@ViewChild('dueTimeSelector', { static: false })
public dueTimeSelector: DueTimeSelectorComponent;
public schema: SchemaDetailsDto;
public tableView: TableFields;
@ -37,27 +40,31 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit {
public selectedAll = false;
public selectionCount = 0;
public selectionCanDelete = false;
public nextStatuses: { [name: string]: string } = {};
public selectionStatuses: { [name: string]: string } = {};
public language: AppLanguageDto;
public languageMaster: AppLanguageDto;
public languages: ReadonlyArray<AppLanguageDto>;
public queryModel: QueryModel;
public queries: Queries;
public queryModel =
combineLatest([
this.schemasState.selectedSchema.pipe(defined()),
this.languagesState.isoLanguages,
this.contentsState.statuses
]).pipe(
map(values => queryModelFromSchema(values[0], values[1], values[2])));
@ViewChild('dueTimeSelector', { static: false })
public dueTimeSelector: DueTimeSelectorComponent;
public queries =
this.schemasState.selectedSchema.pipe(defined(),
map(schema => new Queries(this.uiState, `schemas.${schema.name}`)));
constructor(
public readonly contentsRoute: Router2State,
public readonly contentsState: ContentsState,
public readonly languagesState: LanguagesState,
private readonly appsState: AppsState,
private readonly contributorsState: ContributorsState,
private readonly route: ActivatedRoute,
private readonly router: Router,
private readonly languagesState: LanguagesState,
private readonly schemasState: SchemasState,
private readonly tempService: TempService,
private readonly uiState: UIState
@ -71,26 +78,34 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit {
}
this.own(
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.languagesState.isoMasterLanguage
.subscribe(language => {
this.language = language;
}));
this.own(
this.languagesState.isoLanguages
.subscribe(languages => {
this.languages = languages;
}));
this.own(
this.route.params.pipe(
switchMap(() => this.schemasState.selectedSchema), distinctUntilChanged())
getSchemaName(this.route).pipe(switchMap(() => this.schemasState.selectedSchema.pipe(defined(), take(1))))
.subscribe(schema => {
this.resetSelection();
this.schema = schema;
this.updateQueries();
this.updateTable();
this.tableView = new TableFields(this.uiState, schema);
this.contentsState.loadAndListen(this.contentsRoute);
const initial =
this.contentsRoute.mapTo(this.contentsState)
.withPaging('contents', 10)
.withSynchronizer(QuerySynchronizer.INSTANCE)
.getInitial();
this.contentsState.load(false, initial);
this.contentsRoute.listen();
}));
this.own(
@ -98,28 +113,20 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit {
.subscribe(() => {
this.updateSelectionSummary();
}));
this.own(
this.languagesState.languagesDtos
.subscribe(languages => {
this.languages = languages;
this.language = this.languages.find(x => x.isMaster)!;
this.languageMaster = this.language;
}));
}
public reload() {
this.contentsState.load(true);
}
public deleteSelected() {
this.contentsState.deleteMany(this.selectItems());
}
public delete(content: ContentDto) {
this.contentsState.deleteMany([content]);
}
public deleteSelected() {
this.contentsState.deleteMany(this.selectItems());
}
public changeStatus(content: ContentDto, status: string) {
this.changeContentItems([content], status);
}
@ -137,8 +144,7 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit {
tap(() => {
this.resetSelection();
}),
switchMap(d => this.contentsState.changeManyStatus(contents, action, d)),
onErrorResumeNext())
switchSafe(d => this.contentsState.changeManyStatus(contents, action, d)))
.subscribe();
}
@ -152,25 +158,18 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit {
this.contentsState.search(query);
}
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;
}
public selectItem(content: ContentDto, isSelected: boolean) {
this.selectedItems[content.id] = isSelected;
public resetSelection() {
this.selectedItems = {};
this.updateSelectionSummary();
}
private resetSelection() {
this.selectedItems = {};
public selectItem(content: ContentDto, isSelected: boolean) {
this.selectedItems[content.id] = isSelected;
this.updateSelectionSummary();
}
@ -187,19 +186,15 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit {
this.updateSelectionSummary();
}
public trackByContent(_index: number, content: ContentDto): string {
return content.id;
}
private updateSelectionSummary() {
this.selectedAll = this.contentsState.snapshot.contents.length > 0;
this.selectionCount = 0;
this.selectionCanDelete = true;
this.nextStatuses = {};
this.selectionStatuses = {};
for (const content of this.contentsState.snapshot.contents) {
for (const info of content.statusUpdates) {
this.nextStatuses[info.status] = info.color;
this.selectionStatuses[info.status] = info.color;
}
}
@ -207,10 +202,10 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit {
if (this.selectedItems[content.id]) {
this.selectionCount++;
for (const action in this.nextStatuses) {
if (this.nextStatuses.hasOwnProperty(action)) {
for (const action in this.selectionStatuses) {
if (this.selectionStatuses.hasOwnProperty(action)) {
if (!content.statusUpdates.find(x => x.status === action)) {
delete this.nextStatuses[action];
delete this.selectionStatuses[action];
}
}
}
@ -224,15 +219,15 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit {
}
}
private updateQueries() {
if (this.schema) {
this.queries = new Queries(this.uiState, `schemas.${this.schema.name}`);
}
public trackByContent(_index: number, content: ContentDto): string {
return content.id;
}
private updateTable() {
if (this.schema) {
this.tableView = new TableFields(this.uiState, this.schema);
}
private selectItems(predicate?: (content: ContentDto) => boolean) {
return this.contentsState.snapshot.contents.filter(c => this.selectedItems[c.id] && (!predicate || predicate(c)));
}
}
function getSchemaName(route: ActivatedRoute) {
return route.params.pipe(map(x => x['schemaName'] as string), distinctUntilChanged());
}

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

@ -7,7 +7,7 @@
import { AfterViewInit, ChangeDetectionStrategy, Component, ElementRef, Renderer2, ViewChild } from '@angular/core';
import { Router } from '@angular/router';
import { ApiUrlConfig, ResourceOwner, Types } from '@app/framework/internal';
import { ApiUrlConfig, defined, ResourceOwner, Types } from '@app/framework/internal';
import { AppsState, AuthService, ContentsState, SchemasState } from '@app/shared';
import { combineLatest } from 'rxjs';
@ -44,7 +44,7 @@ export class SidebarPageComponent extends ResourceOwner implements AfterViewInit
public ngAfterViewInit() {
this.own(
combineLatest([
this.schemasState.selectedSchema,
this.schemasState.selectedSchema.pipe(defined()),
this.contentsState.selectedContent
]).subscribe(([schema, content]) => {
const url =

1
frontend/app/features/content/shared/forms/field-editor.component.html

@ -83,6 +83,7 @@
<sqx-references-editor
[formControl]="editorControl"
[allowDuplicates]="field.rawProperties.allowDuplicated"
[formContext]="formContext"
[language]="language"
[languages]="languages"
[schemaIds]="field.rawProperties.schemaIds">

2
frontend/app/features/content/shared/list/content.component.html

@ -37,7 +37,7 @@
[statusColor]="info.color">
</sqx-content-status>
</a>
<a class="dropdown-item" (click)="clone.emit(); dropdown.hide()" *ngIf="canClone">
<a class="dropdown-item" (click)="clone.emit(); dropdown.hide()" *ngIf="cloneable">
{{ 'common.clone' | sqxTranslate }}
</a>

2
frontend/app/features/content/shared/list/content.component.ts

@ -43,7 +43,7 @@ export class ContentComponent implements OnChanges {
public listFields: ReadonlyArray<TableField>;
@Input()
public canClone: boolean;
public cloneable: boolean;
@Input()
public link: any = null;

6
frontend/app/features/content/shared/references/content-creator.component.html

@ -15,7 +15,7 @@
<div class="row no-gutters">
<div class="col-auto">
<div *ngIf="schema && languages.length > 1">
<sqx-language-selector class="languages-buttons"(selectedLanguageChange)="selectLanguage($event)" [languages]="languages"></sqx-language-selector>
<sqx-language-selector class="languages-buttons" [(selectedLanguage)]="language" [languages]="languages"></sqx-language-selector>
</div>
</div>
@ -38,10 +38,10 @@
<form [formGroup]="contentForm.form" (ngSubmit)="saveAndPublish()">
<sqx-content-section *ngFor="let section of contentForm.sections"
[(language)]="language"
[isCompact]="true"
[form]="contentForm"
[formContext]="contentFormContext"
[formContext]="formContext"
[formSection]="section"
[isCompact]="true"
[languages]="languages"
[schema]="schema">
</sqx-content-section>

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

@ -6,7 +6,7 @@
*/
import { ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { ApiUrlConfig, AppLanguageDto, AppsState, AuthService, ContentDto, EditContentForm, LanguageDto, ManualContentsState, ResourceOwner, SchemaDetailsDto, SchemaDto, SchemasState, Types } from '@app/shared';
import { AppLanguageDto, ContentDto, EditContentForm, LanguageDto, ManualContentsState, ResourceOwner, SchemaDetailsDto, SchemaDto, SchemasState, Types } from '@app/shared';
@Component({
selector: 'sqx-content-creator',
@ -29,22 +29,20 @@ export class ContentCreatorComponent extends ResourceOwner implements OnInit {
@Input()
public languages: ReadonlyArray<AppLanguageDto>;
@Input()
public formContext: any;
public schema: SchemaDetailsDto;
public schemas: ReadonlyArray<SchemaDto> = [];
public contentFormContext: any;
public contentForm: EditContentForm;
constructor(authService: AuthService,
public readonly appsState: AppsState,
public readonly apiUrl: ApiUrlConfig,
public readonly contentsState: ManualContentsState,
public readonly schemasState: SchemasState,
constructor(
private readonly contentsState: ManualContentsState,
private readonly schemasState: SchemasState,
private readonly changeDetector: ChangeDetectorRef
) {
super();
this.contentFormContext = { user: authService.user, apiUrl: apiUrl.buildUrl('api') };
}
public ngOnInit() {
@ -68,7 +66,7 @@ export class ContentCreatorComponent extends ResourceOwner implements OnInit {
this.schema = schema;
this.contentsState.schema = schema;
this.contentForm = new EditContentForm(this.languages, this.schema, this.contentFormContext.user);
this.contentForm = new EditContentForm(this.languages, this.schema, this.formContext.user);
this.changeDetector.markForCheck();
}
@ -108,7 +106,7 @@ export class ContentCreatorComponent extends ResourceOwner implements OnInit {
if (publish) {
return this.schema.canContentsCreateAndPublish;
} else {
return this.schema.canContentsCreateAndPublish;
return this.schema.canContentsCreate;
}
}
@ -119,8 +117,4 @@ export class ContentCreatorComponent extends ResourceOwner implements OnInit {
public emitSelect(content: ContentDto) {
this.select.emit([content]);
}
public selectLanguage(language: LanguageDto) {
this.language = language;
}
}

8
frontend/app/features/content/shared/references/content-selector.component.html

@ -28,9 +28,7 @@
</div>
<div class="col-auto pl-1" *ngIf="languages.length > 1">
<sqx-language-selector class="languages-buttons"
(selectedLanguageChange)="selectLanguage($event)" [languages]="languages">
</sqx-language-selector>
<sqx-language-selector class="languages-buttons" [(selectedLanguage)]="language" [languages]="languages"></sqx-language-selector>
</div>
</ng-container>
</div>
@ -72,11 +70,11 @@
<table class="table table-items table-fixed" [style.minWidth]="schema.defaultReferenceFields | sqxContentListWidth" *ngIf="contentsState.contents | async; let contents" [sqxSyncWidth]="header">
<tbody *ngFor="let content of contents; trackBy: trackByContent"
[sqxContentSelectorItem]="content"
[language]="language"
[schema]="schema"
[selectable]="!isItemAlreadySelected(content)"
[selected]="isItemSelected(content)"
(selectedChange)="selectContent(content)"
[language]="language">
(selectedChange)="selectContent(content)">
</tbody>
</table>
</div>

6
frontend/app/features/content/shared/references/content-selector.component.ts

@ -61,7 +61,7 @@ export class ContentSelectorComponent extends ResourceOwner implements OnInit {
this.updateModel();
}));
this.schemas = this.schemasState.snapshot.schemas;
this.schemas = this.schemasState.snapshot.schemas.filter(x => x.canReadContents);
if (this.schemaIds && this.schemaIds.length > 0) {
this.schemas = this.schemas.filter(x => this.schemaIds.indexOf(x.id) >= 0);
@ -114,10 +114,6 @@ export class ContentSelectorComponent extends ResourceOwner implements OnInit {
this.select.emit(Object.values(this.selectedItems));
}
public selectLanguage(language: LanguageDto) {
this.language = language;
}
public selectAll(isSelected: boolean) {
this.selectedItems = {};

1
frontend/app/features/content/shared/references/references-editor.component.html

@ -37,6 +37,7 @@
<ng-container *sqxModal="contentCreatorDialog">
<sqx-content-creator
(select)="select($event)"
[formContext]="formContext"
[language]="language"
[languages]="languages"
[schemaIds]="schemaIds">

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

@ -41,6 +41,9 @@ export class ReferencesEditorComponent extends StatefulControlComponent<State, R
@Input()
public languages: ReadonlyArray<AppLanguageDto>;
@Input()
public formContext: any;
@Input()
public allowDuplicates = true;

16
frontend/app/features/dashboard/pages/dashboard-page.component.html

@ -1,14 +1,14 @@
<sqx-title message="i18n:dashboard.pageTitle"></sqx-title>
<ng-container *ngIf="appsState.selectedApp | async; let app">
<div class="dashboard" @fade="">
<div class="dashboard-summary" *ngIf="!isScrolled" @fade="">
<h1 class="dashboard-title">{{ 'dashboard.welcomeTitle' | sqxTranslate: { user: authState.user?.displayName } }}</h1>
<ng-container *ngIf="selectedApp | async; let app">
<div class="dashboard" @fade>
<div class="dashboard-summary" *ngIf="!isScrolled" @fade>
<h1 class="dashboard-title">{{ 'dashboard.welcomeTitle' | sqxTranslate: { user: user } }}</h1>
<div class="subtext" [innerHTML]="'dashboard.welcomeText' | sqxTranslate: { app: app.displayName } | sqxMarkdown"></div>
</div>
<gridster [options]="gridOptions" #grid="">
<gridster [options]="gridOptions" #grid>
<gridster-item [item]="item" *ngFor="let item of gridConfig">
<ng-container [ngSwitch]="item.type">
<ng-container *ngSwitchCase="'schemas'">
@ -66,8 +66,10 @@
</gridster-item>
</gridster>
<div class="dashboard-settings">
<sqx-dashboard-config [app]="app" [needsAttention]="isScrolled" [config]="gridConfig" (configChange)="changeConfig($event)">
<div class="dashboard-settings" *ngIf="grid">
<sqx-dashboard-config [app]="app" [needsAttention]="isScrolled"
[config]="gridConfig"
(configChange)="changeConfig($event)">
</sqx-dashboard-config>
</div>
</div>

22
frontend/app/features/dashboard/pages/dashboard-page.component.ts

@ -8,9 +8,8 @@
// tslint:disable: readonly-array
import { AfterViewInit, Component, NgZone, OnInit, Renderer2, ViewChild } from '@angular/core';
import { AppsState, AuthService, CallsUsageDto, CurrentStorageDto, DateTime, fadeAnimation, LocalStoreService, ResourceOwner, Settings, StorageUsagePerDateDto, UsagesService } from '@app/shared';
import { AppsState, AuthService, CallsUsageDto, CurrentStorageDto, DateTime, defined, fadeAnimation, LocalStoreService, ResourceOwner, Settings, StorageUsagePerDateDto, switchSafe, UsagesService } from '@app/shared';
import { GridsterComponent, GridsterConfig, GridsterItem, GridType } from 'angular-gridster2';
import { switchMap } from 'rxjs/operators';
@Component({
selector: 'sqx-dashboard-page',
@ -24,6 +23,8 @@ export class DashboardPageComponent extends ResourceOwner implements AfterViewIn
@ViewChild('grid')
public grid: GridsterComponent;
public selectedApp = this.appsState.selectedApp.pipe(defined());
public isStacked: boolean;
public storageCurrent: CurrentStorageDto;
@ -36,9 +37,11 @@ export class DashboardPageComponent extends ResourceOwner implements AfterViewIn
public isScrolled = false;
public user = this.authState.user?.displayName;
constructor(
public readonly appsState: AppsState,
public readonly authState: AuthService,
private readonly appsState: AppsState,
private readonly authState: AuthService,
private readonly localStore: LocalStoreService,
private readonly renderer: Renderer2,
private readonly usagesService: UsagesService,
@ -54,22 +57,19 @@ export class DashboardPageComponent extends ResourceOwner implements AfterViewIn
const dateFrom = DateTime.today().addDays(-20).toStringFormat('yyyy-MM-dd');
this.own(
this.appsState.selectedApp.pipe(
switchMap(app => this.usagesService.getTodayStorage(app.name)))
this.selectedApp.pipe(switchSafe(app => this.usagesService.getTodayStorage(app.name)))
.subscribe(dto => {
this.storageCurrent = dto;
}));
this.own(
this.appsState.selectedApp.pipe(
switchMap(app => this.usagesService.getStorageUsages(app.name, dateFrom, dateTo)))
this.selectedApp.pipe(switchSafe(app => this.usagesService.getStorageUsages(app.name, dateFrom, dateTo)))
.subscribe(dtos => {
this.storageUsage = dtos;
}));
this.own(
this.appsState.selectedApp.pipe(
switchMap(app => this.usagesService.getCallsUsages(app.name, dateFrom, dateTo)))
this.selectedApp.pipe(switchSafe(app => this.usagesService.getCallsUsages(app.name, dateFrom, dateTo)))
.subscribe(dto => {
this.callsUsage = dto;
}));
@ -100,7 +100,7 @@ export class DashboardPageComponent extends ResourceOwner implements AfterViewIn
public changeConfig(config: GridsterItem[]) {
this.gridConfig = config;
this.grid.updateGrid();
this.grid?.updateGrid();
}
}

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

@ -26,7 +26,14 @@ export class RuleEventsPageComponent implements OnInit {
}
public ngOnInit() {
this.ruleEventsState.loadAndListen(this.ruleEventsRoute);
const initial =
this.ruleEventsRoute.mapTo(this.ruleEventsState)
.withPaging('rules', 30)
.withString('query')
.getInitial();
this.ruleEventsState.load(false, initial);
this.ruleEventsRoute.unlisten();
}
public reload() {

2
frontend/app/features/schemas/pages/schema/fields/field-wizard.component.html

@ -65,7 +65,7 @@
<form [formGroup]="editForm.form" class="edit-form" (ngSubmit)="save()">
<sqx-field-form
[patterns]="patternsState.patterns | async"
[languages]="languagesState.languagesDtos | async"
[languages]="languagesState.isoLanguages | async"
[field]="field"
[fieldForm]="editForm.form"
[isEditable]="true"

12
frontend/app/features/schemas/pages/schema/fields/forms/field-form.component.html

@ -1,13 +1,19 @@
<div class="table-items-row-details-tabs clearfix">
<ul class="nav nav-tabs2">
<li class="nav-item">
<a class="nav-link" (click)="selectTab(0)" [class.active]="selectedTab === 0">{{ 'schemas.field.tabCommon' | sqxTranslate }}</a>
<a class="nav-link" (click)="selectTab(0)" [class.active]="selectedTab === 0">
{{ 'schemas.field.tabCommon' | sqxTranslate }}
</a>
</li>
<li class="nav-item" [class.hidden]="!field.properties.isContentField">
<a class="nav-link" (click)="selectTab(1)" [class.active]="selectedTab === 1">{{ 'schemas.field.tabValidation' | sqxTranslate }}</a>
<a class="nav-link" (click)="selectTab(1)" [class.active]="selectedTab === 1">
{{ 'schemas.field.tabValidation' | sqxTranslate }}
</a>
</li>
<li class="nav-item" [class.hidden]="!field.properties.isContentField || field.properties.fieldType === 'Array'">
<a class="nav-link" (click)="selectTab(2)" [class.active]="selectedTab === 2">{{ 'schemas.field.tabEditing' | sqxTranslate }}</a>
<a class="nav-link" (click)="selectTab(2)" [class.active]="selectedTab === 2">
{{ 'schemas.field.tabEditing' | sqxTranslate }}
</a>
</li>
</ul>

2
frontend/app/features/schemas/pages/schema/fields/schema-fields.component.html

@ -6,7 +6,7 @@
</button>
</div>
<ng-container *ngIf="languageState.languagesDtos | async; let languages">
<ng-container *ngIf="languageState.isoLanguages | async; let languages">
<ng-container *ngIf="patternsState.patterns | async; let patterns">
<div
cdkDropList

44
frontend/app/features/schemas/pages/schema/schema-page.component.html

@ -1,10 +1,32 @@
<sqx-title [message]="schemasState.schemaName"></sqx-title>
<sqx-panel desiredWidth="50rem" showSidebar="true" noPadding="true">
<sqx-panel desiredWidth="50rem" showSidebar="true" noPadding="true" *ngIf="schemaTab | async; let tab">
<ng-container header>
<ul class="nav nav-tabs2">
<li class="nav-item" *ngFor="let tab of selectableTabs">
<a class="nav-link" [class.active]="tab === selectedTab" (click)="selectTab(tab)">{{tab | sqxTranslate}}</a>
<li class="nav-item">
<a class="nav-link" routerLink="./" [queryParams]="{ tab: 'fields' }" [class.active]="tab === 'fields'">
{{ 'i18n:schemas.tabFields' | sqxTranslate }}
</a>
</li>
<li>
<a class="nav-link" routerLink="./" [queryParams]="{ tab: 'ui' }" [class.active]="tab === 'ui'">
{{ 'i18n:schemas.tabUI' | sqxTranslate }}
</a>
</li>
<li>
<a class="nav-link" routerLink="./" [queryParams]="{ tab: 'scripts' }" [class.active]="tab === 'scripts'">
{{ 'i18n:schemas.tabScripts' | sqxTranslate }}
</a>
</li>
<li>
<a class="nav-link" routerLink="./" [queryParams]="{ tab: 'json' }" [class.active]="tab === 'json'">
{{ 'i18n:schemas.tabJson' | sqxTranslate }}
</a>
</li>
<li>
<a class="nav-link" routerLink="./" [queryParams]="{ tab: 'more' }" [class.active]="tab === 'more'">
{{ 'i18n:schemas.tabMore' | sqxTranslate }}
</a>
</li>
</ul>
</ng-container>
@ -57,26 +79,26 @@
</ng-container>
<ng-container content>
<ng-container [ngSwitch]="selectedTab">
<ng-container *ngSwitchCase="'i18n:schemas.tabFields'">
<sqx-schema-fields [schema]="schema"></sqx-schema-fields>
</ng-container>
<ng-container *ngSwitchCase="'i18n:schemas.tabUI'">
<ng-container [ngSwitch]="tab">
<ng-container *ngSwitchCase="'ui'">
<sqx-schema-ui-form [schema]="schema"></sqx-schema-ui-form>
</ng-container>
<ng-container *ngSwitchCase="'i18n:schemas.tabScripts'">
<ng-container *ngSwitchCase="'scripts'">
<sqx-schema-scripts-form [schema]="schema"></sqx-schema-scripts-form>
</ng-container>
<ng-container *ngSwitchCase="'i18n:schemas.tabJson'">
<ng-container *ngSwitchCase="'json'">
<sqx-schema-export-form [schema]="schema"></sqx-schema-export-form>
</ng-container>
<ng-container *ngSwitchCase="'i18n:schemas.tabMore'">
<ng-container *ngSwitchCase="'more'">
<div class="cards">
<sqx-schema-preview-urls-form [schema]="schema"> </sqx-schema-preview-urls-form>
<sqx-schema-field-rules-form [schema]="schema"> </sqx-schema-field-rules-form>
<sqx-schema-edit-form [schema]="schema"></sqx-schema-edit-form>
</div>
</ng-container>
<ng-container *ngSwitchDefault>
<sqx-schema-fields [schema]="schema"></sqx-schema-fields>
</ng-container>
</ng-container>
</ng-container>

21
frontend/app/features/schemas/pages/schema/schema-page.component.ts

@ -7,17 +7,10 @@
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { fadeAnimation, MessageBus, ModalModel, ResourceOwner, SchemaDetailsDto, SchemasState } from '@app/shared';
import { defined, fadeAnimation, MessageBus, ModalModel, ResourceOwner, SchemaDetailsDto, SchemasState } from '@app/shared';
import { map } from 'rxjs/operators';
import { SchemaCloning } from './../messages';
const TABS: ReadonlyArray<string> = [
'i18n:schemas.tabFields',
'i18n:schemas.tabUI',
'i18n:schemas.tabScripts',
'i18n:schemas.tabJson',
'i18n:schemas.tabMore'
];
@Component({
selector: 'sqx-schema-page',
styleUrls: ['./schema-page.component.scss'],
@ -30,9 +23,7 @@ export class SchemaPageComponent extends ResourceOwner implements OnInit {
public readonly exact = { exact: true };
public schema: SchemaDetailsDto;
public selectableTabs: ReadonlyArray<string> = TABS;
public selectedTab = this.selectableTabs[0];
public schemaTab = this.route.queryParams.pipe(map(x => x['tab'] || 'fields'));
public editOptionsDropdown = new ModalModel();
@ -47,7 +38,7 @@ export class SchemaPageComponent extends ResourceOwner implements OnInit {
public ngOnInit() {
this.own(
this.schemasState.selectedSchema
this.schemasState.selectedSchema.pipe(defined())
.subscribe(schema => {
this.schema = schema;
}));
@ -72,10 +63,6 @@ export class SchemaPageComponent extends ResourceOwner implements OnInit {
});
}
public selectTab(tab: string) {
this.selectedTab = tab;
}
private back() {
this.router.navigate(['../'], { relativeTo: this.route });
}

4
frontend/app/features/schemas/pages/schema/scripts/schema-scripts-form.component.html

@ -2,7 +2,9 @@
<div class="inner-header">
<ul class="nav nav-tabs2">
<li class="nav-item" *ngFor="let script of editForm.form.controls | sqxKeys">
<a class="nav-link" [class.active]="selectedField === script" (click)="selectField(script)">{{script | titlecase}}</a>
<a class="nav-link" [class.active]="selectedField === script" (click)="selectField(script)">
{{script | titlecase}}
</a>
</li>
</ul>

14
frontend/app/features/schemas/pages/schema/ui/schema-ui-form.component.html

@ -1,8 +1,14 @@
<form class="inner-form" (ngSubmit)="saveSchema()">
<div class="inner-header">
<ul class="nav nav-tabs2">
<li class="nav-item" *ngFor="let tab of selectableTabs">
<a class="nav-link" [class.active]="selectedTab === tab" (click)="selectTab(tab)">{{tab | sqxTranslate}}</a>
<li class="nav-item">
<a class="nav-link" (click)="selectTab(0)" [class.active]="selectedTab === 0">
{{ 'i18n:schemas.listFields' | sqxTranslate }}</a>
</li>
<li class="nav-item">
<a class="nav-link" (click)="selectTab(1)" [class.active]="selectedTab === 1">
{{ 'i18n:schemas.referenceFields' | sqxTranslate}}
</a>
</li>
</ul>
@ -12,7 +18,7 @@
</div>
<div class="inner-main">
<sqx-field-list [class.hidden]="selectedTab !== 'i18n:schemas.listFields'"
<sqx-field-list [class.hidden]="selectedTab !== 0"
[emptyText]="'schemas.listFieldsEmpty' | sqxTranslate"
[schema]="schema"
[fieldNames]="state.fieldsInLists"
@ -20,7 +26,7 @@
[withMetaFields]="true">
</sqx-field-list>
<sqx-field-list [class.hidden]="selectedTab !== 'i18n:schemas.referenceFields'"
<sqx-field-list [class.hidden]="selectedTab !== 1"
[emptyText]="'schemas.referenceFieldsEmpty' | sqxTranslate"
[schema]="schema"
[fieldNames]="state.fieldsInReferences"

10
frontend/app/features/schemas/pages/schema/ui/schema-ui-form.component.ts

@ -10,11 +10,6 @@ import { SchemaDetailsDto, SchemasState } from '@app/shared';
type State = { fieldsInLists: ReadonlyArray<string>, fieldsInReferences: ReadonlyArray<string> };
const TABS: ReadonlyArray<string> = [
'i18n:schemas.listFields',
'i18n:schemas.referenceFields'
];
@Component({
selector: 'sqx-schema-ui-form',
styleUrls: ['./schema-ui-form.component.scss'],
@ -24,8 +19,7 @@ export class SchemaUIFormComponent implements OnChanges {
@Input()
public schema: SchemaDetailsDto;
public selectableTabs = TABS;
public selectedTab = this.selectableTabs[0];
public selectedTab = 0;
public isEditable = false;
@ -56,7 +50,7 @@ export class SchemaUIFormComponent implements OnChanges {
this.state.fieldsInReferences = names;
}
public selectTab(tab: string) {
public selectTab(tab: number) {
this.selectedTab = tab;
}

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

@ -29,7 +29,14 @@ export class ContributorsPageComponent implements OnInit {
public ngOnInit() {
this.rolesState.load();
this.contributorsState.loadAndListen(this.contributorsRoute);
const initial =
this.contributorsRoute.mapTo(this.contributorsState)
.withPaging('contributors', 10)
.withString('query')
.getInitial();
this.contributorsState.load(false, initial);
this.contributorsRoute.unlisten();
}
public reload() {

4
frontend/app/features/settings/pages/more/more-page.component.ts

@ -8,7 +8,7 @@
import { Component, OnInit } from '@angular/core';
import { FormBuilder } from '@angular/forms';
import { Router } from '@angular/router';
import { AppDto, AppsState, ResourceOwner, Types, UpdateAppForm } from '@app/shared';
import { AppDto, AppsState, defined, ResourceOwner, Types, UpdateAppForm } from '@app/shared';
@Component({
selector: 'sqx-more-page',
@ -37,7 +37,7 @@ export class MorePageComponent extends ResourceOwner implements OnInit {
public ngOnInit() {
this.own(
this.appsState.selectedApp
this.appsState.selectedApp.pipe(defined())
.subscribe(app => {
this.app = app;

8
frontend/app/features/settings/pages/workflows/workflow.component.html

@ -33,10 +33,14 @@
<div class="table-items-row-details-tabs clearfix">
<ul class="nav nav-tabs2">
<li class="nav-item">
<a class="nav-link" (click)="selectTab(0)" [class.active]="selectedTab === 0">{{ 'workflows.tabEdit' | sqxTranslate }}</a>
<a class="nav-link" (click)="selectTab(0)" [class.active]="selectedTab === 0">
{{ 'workflows.tabEdit' | sqxTranslate }}
</a>
</li>
<li class="nav-item">
<a class="nav-link" (click)="selectTab(1)" [class.active]="selectedTab === 1">{{ 'workflows.tabVisualize' | sqxTranslate }}</a>
<a class="nav-link" (click)="selectTab(1)" [class.active]="selectedTab === 1">
{{ 'workflows.tabVisualize' | sqxTranslate }}
</a>
</li>
</ul>

2
frontend/app/features/settings/settings-area.component.html

@ -8,7 +8,7 @@
</ng-container>
<ng-container content>
<ul class="nav nav-panel nav-dark flex-column" *ngIf="appsState.selectedApp | async; let app">
<ul class="nav nav-panel nav-dark flex-column" *ngIf="selectedApp | async; let app">
<li class="nav-item" *ngIf="app.canReadBackups">
<a class="nav-link" routerLink="backups" routerLinkActive="active">
{{ 'common.backups' | sqxTranslate }}

6
frontend/app/features/settings/settings-area.component.ts

@ -6,7 +6,7 @@
*/
import { Component } from '@angular/core';
import { AppsState } from '@app/shared';
import { AppsState, defined } from '@app/shared';
@Component({
selector: 'sqx-settings-area',
@ -14,8 +14,10 @@ import { AppsState } from '@app/shared';
templateUrl: './settings-area.component.html'
})
export class SettingsAreaComponent {
public selectedApp = this.appsState.selectedApp.pipe(defined());
constructor(
public readonly appsState: AppsState
private readonly appsState: AppsState
) {
}
}

4
frontend/app/framework/angular/language-selector.component.ts

@ -24,13 +24,13 @@ export class LanguageSelectorComponent implements OnChanges, OnInit {
public selectedLanguageChange = new EventEmitter<Language>();
@Input()
public size: 'sm' | 'md' | 'lg' = 'md';
public selectedLanguage: Language;
@Input()
public languages: ReadonlyArray<Language> = [];
@Input()
public selectedLanguage: Language;
public size: 'sm' | 'md' | 'lg' = 'md';
public dropdown = new ModalModel();

6
frontend/app/framework/angular/routers/can-deactivate.guard.ts

@ -6,16 +6,16 @@
*/
import { Injectable } from '@angular/core';
import { CanDeactivate } from '@angular/router';
import { CanDeactivate, UrlTree } from '@angular/router';
import { Observable } from 'rxjs';
export interface CanComponentDeactivate {
canDeactivate(): Observable<boolean>;
canDeactivate(): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree;
}
@Injectable()
export class CanDeactivateGuard implements CanDeactivate<CanComponentDeactivate> {
public canDeactivate(component: CanComponentDeactivate) {
return component.canDeactivate ? component.canDeactivate() : true;
return component?.canDeactivate ? component.canDeactivate() : true;
}
}

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

@ -5,96 +5,80 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { NavigationEnd, NavigationExtras, NavigationStart, Params, Router } from '@angular/router';
import { LocalStoreService, MathHelper } from '@app/framework/internal';
import { BehaviorSubject, Subject } from 'rxjs';
import { NavigationExtras, Params, Router } from '@angular/router';
import { LocalStoreService } from '@app/framework/internal';
import { IMock, It, Mock, Times } from 'typemoq';
import { State } from './../../state';
import { PagingSynchronizer, Router2State, StringKeysSynchronizer, StringSynchronizer } from './router-2-state';
import { PagingSynchronizer, QueryParams, Router2State, StringKeysSynchronizer, StringSynchronizer } from './router-2-state';
describe('Router2State', () => {
describe('Strings', () => {
const synchronizer = new StringSynchronizer('key', 'key');
it('should write string to route', () => {
const params: Params = {};
const synchronizer = new StringSynchronizer('key');
it('should parse from state', () => {
const value = 'my-string';
synchronizer.writeValuesToRoute({ key: value }, params);
const query = synchronizer.parseFromState({ key: value });
expect(params).toEqual({ key: 'my-string' });
expect(query).toEqual({ key: 'my-string' });
});
it('Should write undefined when not a string', () => {
const params: Params = {};
it('should parse from state as undefined when not a string', () => {
const value = 123;
synchronizer.writeValuesToRoute(value, params);
const query = synchronizer.parseFromState({ key: value });
expect(params).toEqual({ key: undefined });
expect(query).toBeUndefined();
});
it('should get string from route', () => {
const params: Params = {
key: 'my-string'
};
const params: QueryParams = { key: 'my-string' };
const value = synchronizer.parseValuesFromRoute(params);
const value = synchronizer.parseFromRoute(params);
expect(value).toEqual({ key: 'my-string' });
});
});
describe('StringKeys', () => {
const synchronizer = new StringKeysSynchronizer('key', 'key');
const synchronizer = new StringKeysSynchronizer('key');
it('should write object keys to route', () => {
const params: Params = {};
it('should parse from state', () => {
const value = { flag1: true, flag2: true };
const value = {
flag1: true,
flag2: false
};
synchronizer.writeValuesToRoute({ key: value }, params);
const query = synchronizer.parseFromState({ key: value });
expect(params).toEqual({ key: 'flag1,flag2' });
expect(query).toEqual({ key: 'flag1,flag2' });
});
it('Should write undefined when empty', () => {
const params: Params = {};
const value = {};
it('should parse from state as undefined when empty', () => {
const value = 123;
synchronizer.writeValuesToRoute({ key: value }, params);
const query = synchronizer.parseFromState({ key: value });
expect(params).toEqual({ key: undefined });
expect(query).toBeUndefined();
});
it('Should write undefined when not an object', () => {
const params: Params = {};
it('should parse from state as undefined when not an object', () => {
const value = 123;
synchronizer.writeValuesToRoute({ key: value }, params);
const query = synchronizer.parseFromState({ key: value });
expect(params).toEqual({ key: undefined });
expect(query).toBeUndefined();
});
it('should get object from route', () => {
const params: Params = { key: 'flag1,flag2' };
const params: QueryParams = { key: 'flag1,flag2' };
const value = synchronizer.parseValuesFromRoute(params);
const value = synchronizer.parseFromRoute(params);
expect(value).toEqual({ key: { flag1: true, flag2: true } });
});
it('should get object with empty keys from route', () => {
const params: Params = { key: 'flag1,,,flag2' };
const params: QueryParams = { key: 'flag1,,,flag2' };
const value = synchronizer.parseValuesFromRoute(params);
const value = synchronizer.parseFromRoute(params);
expect(value).toEqual({ key: { flag1: true, flag2: true } });
});
@ -110,10 +94,30 @@ describe('Router2State', () => {
synchronizer = new PagingSynchronizer(localStore.object, 'contents', 30);
});
it('should parse from state', () => {
const state = { page: 10, pageSize: 20 };
const query = synchronizer.parseFromState(state);
expect(query).toEqual({ page: '10', pageSize: '20' });
localStore.verify(x => x.setInt('contents.pageSize', 20), Times.once());
});
it('should parse from state without page when zero', () => {
const state = { page: 0, pageSize: 20 };
const query = synchronizer.parseFromState(state);
expect(query).toEqual({ page: undefined, pageSize: '20' });
localStore.verify(x => x.setInt('contents.pageSize', 20), Times.once());
});
it('should get page and size from route', () => {
const params: Params = { page: '10', pageSize: '40' };
const value = synchronizer.parseValuesFromRoute(params);
const value = synchronizer.parseFromRoute(params);
expect(value).toEqual({ page: 10, pageSize: 40 });
});
@ -124,7 +128,7 @@ describe('Router2State', () => {
const params: Params = { page: '10' };
const value = synchronizer.parseValuesFromRoute(params);
const value = synchronizer.parseFromRoute(params);
expect(value).toEqual({ page: 10, pageSize: 40 });
});
@ -135,7 +139,7 @@ describe('Router2State', () => {
const params: Params = { page: '10' };
const value = synchronizer.parseValuesFromRoute(params);
const value = synchronizer.parseFromRoute(params);
expect(value).toEqual({ page: 10, pageSize: 30 });
});
@ -143,7 +147,7 @@ describe('Router2State', () => {
it('should get page size from default as last fallback', () => {
const params: Params = { page: '10' };
const value = synchronizer.parseValuesFromRoute(params);
const value = synchronizer.parseFromRoute(params);
expect(value).toEqual({ page: 10, pageSize: 30 });
});
@ -151,176 +155,58 @@ describe('Router2State', () => {
it('should fix page number if invalid', () => {
const params: Params = { page: '-10' };
const value = synchronizer.parseValuesFromRoute(params);
const value = synchronizer.parseFromRoute(params);
expect(value).toEqual({ page: 0, pageSize: 30 });
});
it('should write pager to route and local store', () => {
const params: Params = {};
synchronizer.writeValuesToRoute({ page: 10, pageSize: 20 }, params);
expect(params).toEqual({ page: '10', pageSize: '20' });
localStore.verify(x => x.setInt('contents.pageSize', 20), Times.once());
});
it('Should write undefined when page number is zero', () => {
const params: Params = {};
synchronizer.writeValuesToRoute({ page: 0, pageSize: 20 }, params);
expect(params).toEqual({ page: undefined, pageSize: '20' });
localStore.verify(x => x.setInt('contents.pageSize', 20), Times.once());
});
});
describe('Implementation', () => {
let localStore: IMock<LocalStoreService>;
let routerQueryParams: BehaviorSubject<Params>;
let routerEvents: Subject<any>;
let queryParams: QueryParams = {};
let route: any;
let router: IMock<Router>;
let router2State: Router2State;
let state: State<any>;
let invoked = 0;
beforeEach(() => {
localStore = Mock.ofType<LocalStoreService>();
routerEvents = new Subject<any>();
queryParams = {};
router = Mock.ofType<Router>();
router.setup(x => x.events).returns(() => routerEvents);
route = {
snapshot: {
queryParams
}
};
state = new State<any>({});
routerQueryParams = new BehaviorSubject<Params>({});
route = { queryParams: routerQueryParams, id: MathHelper.guid() };
router2State = new Router2State(route, router.object, localStore.object);
router2State.mapTo(state)
.keep('keep')
.withString('state1', 'key1')
.withString('state2', 'key2')
.whenSynced(() => { invoked++; })
.build();
invoked = 0;
.withString('state1')
.withStrings('state2')
.listen();
});
afterEach(() => {
router2State.ngOnDestroy();
});
it('should unsubscribe from route and state', () => {
it('should unsubscribe from state', () => {
router2State.ngOnDestroy();
expect(state.changes['observers'].length).toEqual(0);
expect(route.queryParams.observers.length).toEqual(0);
expect(routerEvents.observers.length).toEqual(0);
});
it('Should sync from route', () => {
routerQueryParams.next({
key1: 'hello',
key2: 'squidex'
});
it('Should get values from route', () => {
queryParams['state1'] = 'hello';
queryParams['state2'] = 'squidex,cms';
expect(state.snapshot.state1).toEqual('hello');
expect(state.snapshot.state2).toEqual('squidex');
});
const values = router2State.getInitial();
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 not sync again when no value has changed', () => {
routerQueryParams.next({
key1: 'hello',
key2: 'squidex'
});
routerQueryParams.next({
key1: 'hello',
key2: 'squidex',
key3: undefined,
key4: null
});
expect(invoked).toEqual(1);
});
it('Should sync again when new query changed', () => {
routerQueryParams.next({
key1: 'hello',
key2: 'squidex'
});
routerQueryParams.next({
key1: 'hello',
key2: 'cms',
key3: '!'
});
expect(invoked).toEqual(2);
});
it('Should not sync again when no state as changed', () => {
routerQueryParams.next({
key1: 'hello',
key2: 'squidex'
});
routerQueryParams.next({
key1: 'hello',
key2: 'squidex',
key3: '!'
});
expect(invoked).toEqual(1);
});
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);
expect(values).toEqual({ state1: 'hello', state2: { squidex: true, cms: true } });
});
it('Should sync from state', () => {
@ -331,47 +217,37 @@ describe('Router2State', () => {
state.next({
state1: 'hello',
state2: 'squidex'
state2: { squidex: true, cms: true }
});
expect(routeExtras!.replaceUrl).toBeTrue();
expect(routeExtras!.queryParamsHandling).toBe('merge');
expect(routeExtras!.queryParams).toEqual({ key1: 'hello', key2: 'squidex' });
});
expect(routeExtras!.queryParams).toEqual({ state1: 'hello', state2: 'squidex,cms' });
it('Should not sync when navigating', () => {
routerEvents.next(new NavigationStart(0, ''));
state.next({
state1: 'hello',
state2: 'squidex'
});
router.verify(x => x.navigate(It.isAny(), It.isAny()), Times.never());
expect().nothing();
router.verify(x => x.navigate(It.isAny(), It.isAny()), Times.exactly(2));
});
it('Should sync from state delayed when navigating', () => {
it('Should not sync from state again when nothing has changed', () => {
let routeExtras: NavigationExtras;
router.setup(x => x.navigate([], It.isAny()))
.callback((_, extras) => { routeExtras = extras; });
routerEvents.next(new NavigationStart(0, ''));
state.next({
state1: 'hello',
state2: 'squidex'
state2: { squidex: true, cms: true }
});
router.verify(x => x.navigate(It.isAny(), It.isAny()), Times.never());
routerEvents.next(new NavigationEnd(0, '', ''));
state.next({
state1: 'hello',
state2: { squidex: true, cms: true }
});
expect(routeExtras!.replaceUrl).toBeTrue();
expect(routeExtras!.queryParamsHandling).toBe('merge');
expect(routeExtras!.queryParams).toEqual({ key1: 'hello', key2: 'squidex' });
expect(routeExtras!.queryParams).toEqual({ state1: 'hello', state2: 'squidex,cms' });
router.verify(x => x.navigate(It.isAny(), It.isAny()), Times.exactly(2));
});
});
});

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

@ -5,21 +5,28 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
// tslint:disable: forin
// tslint:disable: readonly-array
import { Injectable, OnDestroy } from '@angular/core';
import { ActivatedRoute, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, Params, Router } from '@angular/router';
import { ActivatedRoute, Params, Router } from '@angular/router';
import { LocalStoreService, Types } from '@app/framework/internal';
import { State } from '@app/framework/state';
import { Subscription } from 'rxjs';
export type QueryParams = { [name: string]: string };
export interface RouteSynchronizer {
parseValuesFromRoute(params: Params): object;
readonly keys: ReadonlyArray<string>;
parseFromRoute(query: QueryParams): object | undefined;
writeValuesToRoute(state: any, params: Params): void;
parseFromState(state: any): QueryParams | undefined;
}
export class PagingSynchronizer implements RouteSynchronizer {
public readonly keys = ['page', 'pageSize'];
constructor(
private readonly localStore: LocalStoreService,
private readonly storeName: string,
@ -27,10 +34,10 @@ export class PagingSynchronizer implements RouteSynchronizer {
) {
}
public parseValuesFromRoute(params: Params) {
public parseFromRoute(query: QueryParams) {
let pageSize = 0;
const pageSizeValue = params['pageSize'];
const pageSizeValue = query['pageSize'];
if (Types.isString(pageSizeValue)) {
pageSize = parseInt(pageSizeValue, 10);
@ -44,7 +51,7 @@ export class PagingSynchronizer implements RouteSynchronizer {
pageSize = this.defaultSize;
}
let page = parseInt(params['page'], 10);
let page = parseInt(query['page'], 10);
if (page <= 0 || isNaN(page)) {
page = 0;
@ -53,56 +60,58 @@ export class PagingSynchronizer implements RouteSynchronizer {
return { page, pageSize };
}
public writeValuesToRoute(state: any, params: Params) {
const page: number = state.page;
if (page > 0) {
params['page'] = page.toString();
} else {
params['page'] = undefined;
}
public parseFromState(state: any) {
let page = undefined;
const pageSize: number = state.pageSize;
params['pageSize'] = pageSize.toString();
if (state.page > 0) {
page = state.page.toString();
}
this.localStore.setInt(`${this.storeName}.pageSize`, pageSize);
return { page, pageSize: pageSize.toString() };
}
}
export class StringSynchronizer implements RouteSynchronizer {
public get keys() {
return [this.key];
}
constructor(
private readonly nameState: string,
private readonly nameUrl: string
private readonly key: string
) {
}
public parseValuesFromRoute(params: Params) {
const value = params[this.nameUrl];
public parseFromRoute(params: QueryParams) {
const value = params[this.key];
return { [this.nameState]: value };
return { [this.key]: value };
}
public writeValuesToRoute(state: any, params: Params) {
params[this.nameUrl] = undefined;
const value = state[this.nameState];
public parseFromState(state: any) {
const value = state[this.key];
if (Types.isString(value)) {
params[this.nameUrl] = value;
return { [this.key]: value };
}
}
}
export class StringKeysSynchronizer implements RouteSynchronizer {
public get keys() {
return [this.key];
}
constructor(
private readonly nameState: string,
private readonly nameUrl: string
private readonly key: string
) {
}
public parseValuesFromRoute(params: Params) {
const value = params[this.nameUrl];
public parseFromRoute(query: QueryParams) {
const value = query[this.key];
const result: { [key: string]: boolean } = {};
@ -114,19 +123,17 @@ export class StringKeysSynchronizer implements RouteSynchronizer {
}
}
return { [this.nameState]: result };
return { [this.key]: result };
}
public writeValuesToRoute(state: any, params: Params) {
params[this.nameUrl] = undefined;
const value = state[this.nameState];
public parseFromState(state: any) {
const value = state[this.key];
if (Types.isObject(value)) {
const items = Object.keys(value).join(',');
if (items.length > 0) {
params[this.nameUrl] = items;
return { [this.key]: items };
}
}
}
@ -137,19 +144,15 @@ export interface StateSynchronizer {
}
export interface StateSynchronizerMap<T> {
keep(key: keyof T & string): this;
withString(key: keyof T & string, urlName: string): this;
withString(key: keyof T & string): this;
withStrings(key: keyof T & string, urlName: string): this;
withStrings(key: keyof T & string): this;
withPaging(storeName: string, defaultSize: number): this;
whenSynced(action: () => void): this;
withSynchronizer(synchronizer: RouteSynchronizer): this;
build(): void;
getInitial(): Partial<T>;
}
@Injectable()
@ -163,28 +166,34 @@ export class Router2State implements OnDestroy, StateSynchronizer {
) {
}
public getInitial() {
return this.mapper?.getInitial();
}
public listen() {
this.mapper?.listen();
}
public unlisten() {
this.mapper?.unlisten();
}
public ngOnDestroy() {
this.mapper?.destroy();
this.unlisten();
}
public mapTo<T extends object>(state: State<T>) {
this.mapper?.destroy();
this.mapper = this.mapper || new Router2StateMap<T>(state, this.route, this.router, this.localStore);
this.mapper?.unlisten();
this.mapper = new Router2StateMap<T>(state, this.route, this.router, this.localStore);
return this.mapper;
}
}
export class Router2StateMap<T extends object> implements StateSynchronizerMap<T> {
private readonly syncs: { synchronizer: RouteSynchronizer, value: object }[] = [];
private readonly keysToKeep: string[] = [];
private syncDone: (() => void)[] = [];
private lastSyncedParams: Params | undefined;
private subscriptionChanges: Subscription;
private subscriptionQueryParams: Subscription;
private subscriptionEvents: Subscription;
private isNavigating = false;
private pendingParams?: Params;
private readonly syncs: RouteSynchronizer[] = [];
private lastSyncedQuery: QueryParams;
private stateSubscription: Subscription;
constructor(
private readonly state: State<T>,
@ -194,134 +203,62 @@ export class Router2StateMap<T extends object> implements StateSynchronizerMap<T
) {
}
public build() {
this.subscriptionQueryParams =
this.route.queryParams
.subscribe(q => this.syncFromRoute(q));
this.subscriptionChanges =
this.state.changes
.subscribe(s => this.syncToRoute(s));
this.subscriptionEvents =
this.router.events
.subscribe(event => {
if (Types.is(event, NavigationStart)) {
this.isNavigating = true;
} else if (
Types.is(event, NavigationEnd) ||
Types.is(event, NavigationCancel) ||
Types.is(event, NavigationError)) {
this.isNavigating = false;
if (this.pendingParams) {
this.syncFromParams(this.pendingParams);
}
}
});
}
public listen() {
this.stateSubscription = this.state.changes.subscribe(s => this.syncToRoute(s));
public destroy() {
this.syncDone = [];
return this;
}
this.subscriptionQueryParams?.unsubscribe();
this.subscriptionChanges?.unsubscribe();
this.subscriptionEvents?.unsubscribe();
public unlisten() {
this.stateSubscription?.unsubscribe();
}
private syncToRoute(state: T) {
let isChanged = false;
const query: Params = {};
for (const target of this.syncs) {
if (!target.value) {
isChanged = true;
}
for (const sync of this.syncs) {
const values = sync.parseFromState(state);
for (const key in target.value) {
if (target.value[key] !== state[key]) {
isChanged = true;
break;
}
}
if (isChanged) {
break;
for (const key of sync.keys) {
query[key] = values?.[key];
}
}
if (!isChanged) {
if (Types.equals(this.lastSyncedQuery, query)) {
return;
}
const query: Params = {};
for (const target of this.syncs) {
target.synchronizer.writeValuesToRoute(state, query);
}
if (this.isNavigating) {
this.pendingParams = query;
} else {
this.syncFromParams(query);
}
}
private syncFromParams(query: Params) {
this.pendingParams = undefined;
this.lastSyncedQuery = query;
this.router.navigate([], {
queryParams: query,
queryParamsHandling: 'merge',
replaceUrl: true
});
this.lastSyncedParams = cleanupParams(query);
}
private syncFromRoute(query: Params) {
query = cleanupParams(query);
if (Types.equals(this.lastSyncedParams, query)) {
return;
}
public getInitial() {
const update: Partial<T> = {};
for (const target of this.syncs) {
const values = target.synchronizer.parseValuesFromRoute(query);
for (const key in values) {
if (values.hasOwnProperty(key)) {
update[key] = values[key];
}
}
target.value = values;
}
const query = this.route.snapshot.queryParams;
for (const key of this.keysToKeep) {
update[key] = this.state.snapshot[key];
}
for (const sync of this.syncs) {
const values = sync.parseFromRoute(query);
if (this.state.resetState(update)) {
for (const action of this.syncDone) {
action();
for (const key of sync.keys) {
update[key] = values?.[key];
}
}
}
public keep(key: keyof T & string) {
this.keysToKeep.push(key);
return this;
return update;
}
public withString(key: keyof T & string, urlName: string) {
return this.withSynchronizer(new StringSynchronizer(key, urlName));
public withString(key: keyof T & string) {
return this.withSynchronizer(new StringSynchronizer(key));
}
public withStrings(key: keyof T & string, urlName: string) {
return this.withSynchronizer(new StringKeysSynchronizer(key, urlName));
public withStrings(key: keyof T & string) {
return this.withSynchronizer(new StringKeysSynchronizer(key));
}
public withPaging(storeName: string, defaultSize = 10) {
@ -329,28 +266,8 @@ export class Router2StateMap<T extends object> implements StateSynchronizerMap<T
}
public withSynchronizer(synchronizer: RouteSynchronizer) {
this.syncs.push({ synchronizer, value: {} });
return this;
}
public whenSynced(action: () => void) {
this.syncDone.push(action);
this.syncs.push(synchronizer);
return this;
}
}
function cleanupParams(query: Params) {
for (const key in query) {
if (query.hasOwnProperty(key)) {
const value = query[key];
if (Types.isNull(value) || Types.isUndefined(value)) {
delete query[key];
}
}
}
return query;
}

87
frontend/app/shared/components/assets/asset-dialog.component.html

@ -2,23 +2,40 @@
<sqx-modal-dialog (close)="emitComplete()" size="xl" fullHeight="true" [title]="false" [showFooter]="false">
<ng-container plainTitle>
<ul class="nav nav-tabs2">
<li class="nav-item" *ngFor="let tab of selectableTabs">
<a class="nav-link" [class.active]="tab === selectedTab" (click)="selectTab(tab)">{{tab | sqxTranslate}}</a>
<li class="nav-item">
<a class="nav-link" (click)="selectTab(0)" [class.active]="selectedTab === 0">
{{ 'assets.tabMetadata' | sqxTranslate }}
</a>
</li>
<li class="nav-item">
<a class="nav-link" (click)="selectTab(1)" [class.active]="selectedTab === 1" *ngIf="isImage">
{{ 'assets.tabImage' | sqxTranslate }}
</a>
</li>
<li class="nav-item">
<a class="nav-link" (click)="selectTab(2)" [class.active]="selectedTab === 2" *ngIf="isImage">
{{ 'assets.tabFocusPoint' | sqxTranslate }}
</a>
</li>
<li class="nav-item">
<a class="nav-link" (click)="selectTab(3)" [class.active]="selectedTab === 3">
{{ 'assets.tabHistory' | sqxTranslate }}
</a>
</li>
</ul>
<ng-container [ngSwitch]="selectedTab">
<ng-container *ngSwitchCase="'i18n:assets.tabMetadata'">
<ng-container *ngSwitchCase="0">
<button type="submit" class="btn btn-primary ml-auto mr-4" [class.invisible]="!isEditable">
{{ 'common.save' | sqxTranslate }}
</button>
</ng-container>
<ng-container *ngSwitchCase="'i18n:assets.tabImage'">
<ng-container *ngSwitchCase="1">
<button type="button" class="btn btn-primary ml-auto mr-4" (click)="cropImage()" [class.invisible]="!isUploadable" [disabled]="progress > 0">
{{ 'common.save' | sqxTranslate }}
</button>
</ng-container>
<ng-container *ngSwitchCase="'i18n:assets.tabFocusPoint'">
<ng-container *ngSwitchCase="2">
<button type="button" class="btn btn-primary ml-auto mr-4" (click)="setFocusPoint()" [class.invisible]="!isEditable">
{{ 'common.save' | sqxTranslate }}
</button>
@ -28,35 +45,7 @@
<ng-container content>
<ng-container [ngSwitch]="selectedTab">
<ng-container *ngSwitchCase="'i18n:assets.tabImage'">
<div class="image">
<sqx-image-editor [imageSource]="asset | sqxAssetPreviewUrl"></sqx-image-editor>
<div class="image-progress" *ngIf="progress > 0">
<sqx-progress-bar
[value]="progress"
[strokeWidth]="2"
[trailColor]="'transparent'"
[trailWidth]="0">
</sqx-progress-bar>
</div>
</div>
</ng-container>
<ng-container *ngSwitchCase="'i18n:assets.tabFocusPoint'">
<div>
<sqx-image-focus-point [imageSource]="asset | sqxAssetPreviewUrl" [focusPoint]="asset.metadata"></sqx-image-focus-point>
<div class="image-progress" *ngIf="progress > 0">
<sqx-progress-bar
[value]="progress"
[strokeWidth]="2"
[trailColor]="'transparent'"
[trailWidth]="0">
</sqx-progress-bar>
</div>
</div>
</ng-container>
<ng-container *ngSwitchCase="'i18n:assets.tabMetadata'">
<ng-container *ngSwitchCase="0">
<div class="metadata">
<sqx-form-error [error]="annotateForm.error | async"></sqx-form-error>
@ -167,7 +156,35 @@
</div>
</div>
</ng-container>
<ng-container *ngSwitchCase="'i18n:assets.tabHistory'">
<ng-container *ngSwitchCase="1">
<div class="image">
<sqx-image-editor [imageSource]="asset | sqxAssetPreviewUrl"></sqx-image-editor>
<div class="image-progress" *ngIf="progress > 0">
<sqx-progress-bar
[value]="progress"
[strokeWidth]="2"
[trailColor]="'transparent'"
[trailWidth]="0">
</sqx-progress-bar>
</div>
</div>
</ng-container>
<ng-container *ngSwitchCase="2">
<div>
<sqx-image-focus-point [imageSource]="asset | sqxAssetPreviewUrl" [focusPoint]="asset.metadata"></sqx-image-focus-point>
<div class="image-progress" *ngIf="progress > 0">
<sqx-progress-bar
[value]="progress"
[strokeWidth]="2"
[trailColor]="'transparent'"
[trailWidth]="0">
</sqx-progress-bar>
</div>
</div>
</ng-container>
<ng-container *ngSwitchCase="3">
<sqx-asset-history [asset]="asset"></sqx-asset-history>
</ng-container>
</ng-container>

33
frontend/app/shared/components/assets/asset-dialog.component.ts

@ -15,18 +15,6 @@ import { map } from 'rxjs/operators';
import { ImageCropperComponent } from './image-cropper.component';
import { ImageFocusPointComponent } from './image-focus-point.component';
const TABS_IMAGE: ReadonlyArray<string> = [
'i18n:assets.tabMetadata',
'i18n:assets.tabImage',
'i18n:assets.tabFocusPoint',
'i18n:assets.tabHistory'
];
const TABS_DEFAULT: ReadonlyArray<string> = [
'i18n:assets.tabMetadata',
'i18n:assets.tabHistory'
];
@Component({
selector: 'sqx-asset-dialog',
styleUrls: ['./asset-dialog.component.scss'],
@ -59,11 +47,14 @@ export class AssetDialogComponent implements OnChanges {
public progress = 0;
public selectableTabs: ReadonlyArray<string>;
public selectedTab: string;
public selectedTab = 0;
public annotateForm = new AnnotateAssetForm(this.formBuilder);
public get isImage() {
return this.asset.type === 'Image';
}
constructor(
private readonly appsState: AppsState,
private readonly assetsState: AssetsState,
@ -77,22 +68,14 @@ export class AssetDialogComponent implements OnChanges {
}
public ngOnChanges() {
this.selectTab(0);
this.isEditable = this.asset.canUpdate;
this.isUploadable = this.asset.canUpload;
this.annotateForm.load(this.asset);
this.annotateForm.setEnabled(this.isEditable);
if (this.asset.type === 'Image') {
this.selectableTabs = TABS_IMAGE;
} else {
this.selectableTabs = TABS_DEFAULT;
}
if (this.selectableTabs.indexOf(this.selectedTab) < 0) {
this.selectTab(this.selectableTabs[0]);
}
this.path =
this.assetsService.getAssetFolders(this.appsState.appName, this.asset.parentId).pipe(
map(folders => [ROOT_ITEM, ...folders.path]));
@ -102,7 +85,7 @@ export class AssetDialogComponent implements OnChanges {
this.assetsState.navigate(id);
}
public selectTab(tab: string) {
public selectTab(tab: number) {
this.selectedTab = tab;
}

2
frontend/app/shared/components/assets/asset-uploader.component.html

@ -1,4 +1,4 @@
<ng-container *ngIf="appsState.selectedAppOrNull | async; let app">
<ng-container *ngIf="appsState.selectedApp | async; let app">
<ng-container *ngIf="app.canUploadAssets">
<ul class="nav navbar-nav align-items-center" *ngIf="assetUploader.uploads | async; let uploads" (sqxDropFile)="addFiles($event)">
<li class="nav-item nav-icon dropdown">

3
frontend/app/shared/components/search/queries/filter-comparison.component.html

@ -37,7 +37,8 @@
<ng-container *ngSwitchCase="'number'">
<input type="number" class="form-control"
[ngModel]="filter.value"
(ngModelChange)="changeValue($event)" />
(ngModelChange)="changeValue($event)"
/>
</ng-container>
<ng-container *ngSwitchCase="'reference'">
<sqx-references-dropdown [schemaId]="fieldModel.extra"

5
frontend/app/shared/components/search/search-form.component.html

@ -44,7 +44,10 @@
<ng-container content>
<div class="form-horizontal">
<sqx-query [model]="queryModel" [query]="query" (queryChange)="changeQuery($event)" [language]="language">
<sqx-query [model]="queryModel"
[query]="query"
(queryChange)="changeQuery($event)"
[language]="language">
</sqx-query>
<div class="link" [innerHTML]="'search.help' | sqxTranslate | sqxMarkdown"></div>

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

@ -7,6 +7,7 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from '@angular/router';
import { defined } from '@app/framework';
import { Observable } from 'rxjs';
import { map, take, tap } from 'rxjs/operators';
import { SchemasState } from './../state/schemas.state';
@ -22,6 +23,7 @@ export class SchemaMustNotBeSingletonGuard implements CanActivate {
public canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> {
const result =
this.schemasState.selectedSchema.pipe(
defined(),
take(1),
tap(schema => {
if (schema.isSingleton) {

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

@ -5,8 +5,7 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { StateSynchronizer, StateSynchronizerMap } from '@app/framework';
import { of, Subject } from 'rxjs';
import { of } from 'rxjs';
import { Mock } from 'typemoq';
import { AppsState, AuthService, DateTime, Version } from './../';
@ -23,9 +22,6 @@ const appsState = Mock.ofType<AppsState>();
appsState.setup(x => x.appName)
.returns(() => app);
appsState.setup(x => x.selectedAppOrNull)
.returns(() => of(<any>{ name: app }));
appsState.setup(x => x.selectedApp)
.returns(() => of(<any>{ name: app }));
@ -34,64 +30,10 @@ 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 withPaging() {
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,

11
frontend/app/shared/state/apps.state.ts

@ -6,7 +6,7 @@
*/
import { Injectable } from '@angular/core';
import { defined, DialogService, shareSubscribed, State, Types } from '@app/framework';
import { DialogService, shareSubscribed, State, Types } from '@app/framework';
import { Observable, of } from 'rxjs';
import { catchError, tap } from 'rxjs/operators';
import { AppDto, AppsService, CreateAppDto, UpdateAppDto } from './../services/apps.service';
@ -24,11 +24,8 @@ export class AppsState extends State<Snapshot> {
public apps =
this.project(s => s.apps);
public selectedAppOrNull =
this.project(s => s.selectedApp);
public selectedApp =
this.selectedAppOrNull.pipe(defined());
this.project(s => s.selectedApp);
public get appName() {
return this.snapshot.selectedApp?.name || '';
@ -38,10 +35,6 @@ export class AppsState extends State<Snapshot> {
return this.snapshot.selectedApp?.id || '';
}
public get appDisplayName() {
return this.snapshot.selectedApp?.displayName || '';
}
constructor(
private readonly appsService: AppsService,
private readonly dialogs: DialogService

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

@ -17,7 +17,6 @@ describe('AssetsState', () => {
const {
app,
appsState,
buildDummyStateSynchronizer,
newVersion
} = TestValues;
@ -108,20 +107,6 @@ describe('AssetsState', () => {
expect().nothing();
});
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.loadAndListen(synchronizer);
trigger();
trigger();
expect().nothing();
});
});
describe('Navigating', () => {

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

@ -6,12 +6,12 @@
*/
import { Injectable } from '@angular/core';
import { compareStrings, DialogService, ErrorDto, getPagingInfo, ListState, MathHelper, shareSubscribed, State, StateSynchronizer } from '@app/framework';
import { compareStrings, DialogService, ErrorDto, getPagingInfo, ListState, MathHelper, shareSubscribed, State } from '@app/framework';
import { EMPTY, forkJoin, Observable, of, throwError } from 'rxjs';
import { catchError, finalize, switchMap, tap } from 'rxjs/operators';
import { AnnotateAssetDto, AssetDto, AssetFolderDto, AssetFoldersDto, AssetsService, RenameAssetFolderDto } from './../services/assets.service';
import { AppsState } from './apps.state';
import { Query, QueryFullTextSynchronizer } from './query';
import { Query } from './query';
export type AssetPathItem = { id: string, folderName: string };
@ -123,19 +123,9 @@ export class AssetsState extends State<Snapshot> {
});
}
public loadAndListen(synchronizer: StateSynchronizer) {
synchronizer.mapTo(this)
.withPaging('assets', 30)
.withString('parentId', 'parent')
.withStrings('tagsSelected', 'tags')
.withSynchronizer(QueryFullTextSynchronizer.INSTANCE)
.whenSynced(() => this.loadInternal(false))
.build();
}
public load(isReload = false): Observable<any> {
public load(isReload = false, update: Partial<Snapshot> = {}): Observable<any> {
if (!isReload) {
this.resetState();
this.resetState(update);
}
return this.loadInternal(isReload);

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

@ -6,13 +6,13 @@
*/
import { Injectable } from '@angular/core';
import { DialogService, getPagingInfo, ListState, shareSubscribed, State, StateSynchronizer, Types, Version, Versioned } from '@app/framework';
import { DialogService, getPagingInfo, ListState, shareSubscribed, State, Types, Version, Versioned } from '@app/framework';
import { EMPTY, Observable, of } from 'rxjs';
import { catchError, finalize, map, switchMap, tap } from 'rxjs/operators';
import { BulkResultDto, BulkUpdateJobDto, ContentDto, ContentsDto, ContentsService, StatusInfo } from './../services/contents.service';
import { AppsState } from './apps.state';
import { SavedQuery } from './queries';
import { Query, QuerySynchronizer } from './query';
import { Query } from './query';
import { SchemasState } from './schemas.state';
interface Snapshot extends ListState<Query> {
@ -124,30 +124,21 @@ export abstract class ContentsStateBase extends State<Snapshot> {
}));
}
public loadAndListen(synchronizer: StateSynchronizer) {
synchronizer.mapTo(this)
.keep('selectedContent')
.withPaging('contents', 10)
.withSynchronizer(QuerySynchronizer.INSTANCE)
.whenSynced(() => this.loadInternal(false))
.build();
}
public loadReference(contentId: string) {
this.resetState({ reference: contentId });
public loadReference(contentId: string, update: Partial<Snapshot> = {}) {
this.resetState({ reference: contentId, referencing: undefined, ...update });
return this.loadInternal(false);
}
public loadReferencing(contentId: string) {
this.resetState({ referencing: contentId });
public loadReferencing(contentId: string, update: Partial<Snapshot> = {}) {
this.resetState({ referencing: contentId, reference: undefined, ...update });
return this.loadInternal(false);
}
public load(isReload = false): Observable<any> {
public load(isReload = false, update: Partial<Snapshot> = {}): Observable<any> {
if (!isReload) {
this.resetState({ selectedContent: this.snapshot.selectedContent });
this.resetState({ selectedContent: this.snapshot.selectedContent, ...update });
}
return this.loadInternal(isReload);

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

@ -17,7 +17,6 @@ describe('ContributorsState', () => {
const {
app,
appsState,
buildDummyStateSynchronizer,
newVersion,
version
} = TestValues;
@ -122,19 +121,6 @@ describe('ContributorsState', () => {
expect(contributorsState.snapshot.pageSize).toEqual(10);
});
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();

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

@ -6,7 +6,7 @@
*/
import { Injectable } from '@angular/core';
import { DialogService, ErrorDto, getPagingInfo, ListState, shareMapSubscribed, shareSubscribed, State, StateSynchronizer, Types, Version } from '@app/framework';
import { DialogService, ErrorDto, getPagingInfo, ListState, shareMapSubscribed, shareSubscribed, State, Types, Version } from '@app/framework';
import { EMPTY, Observable, throwError } from 'rxjs';
import { catchError, finalize, tap } from 'rxjs/operators';
import { AssignContributorDto, ContributorDto, ContributorsPayload, ContributorsService } from './../services/contributors.service';
@ -74,14 +74,6 @@ export class ContributorsState extends State<Snapshot> {
});
}
public loadAndListen(synchronizer: StateSynchronizer) {
synchronizer.mapTo(this)
.withString('query', 'q')
.withPaging('contributors', 10)
.whenSynced(() => this.loadInternal(false))
.build();
}
public loadIfNotLoaded(): Observable<any> {
if (this.snapshot.isLoaded) {
return EMPTY;
@ -90,9 +82,9 @@ export class ContributorsState extends State<Snapshot> {
return this.loadInternal(false);
}
public load(isReload = false): Observable<any> {
public load(isReload = false, update: Partial<Snapshot> = {}): Observable<any> {
if (!isReload) {
this.resetState({ page: 0 });
this.resetState(update);
}
return this.loadInternal(isReload);

9
frontend/app/shared/state/languages.state.ts

@ -58,9 +58,12 @@ export class LanguagesState extends State<Snapshot> {
public languages =
this.project(x => x.languages);
public languagesDtos =
public isoLanguages =
this.project(x => x.languages.map(y => y.language));
public isoMasterLanguage =
this.projectFrom(this.isoLanguages, x => x.find(l => l.isMaster)!);
public newLanguages =
this.project(x => x.allLanguagesNew);
@ -98,7 +101,9 @@ export class LanguagesState extends State<Snapshot> {
private loadInternal(isReload: boolean): Observable<any> {
this.next({ isLoading: true });
return forkJoin(this.getAllLanguages(), this.getAppLanguages()).pipe(
return forkJoin([
this.getAllLanguages(),
this.getAppLanguages()]).pipe(
map(args => {
return { allLanguages: args[0], languages: args[1] };
}),

80
frontend/app/shared/state/query.spec.ts

@ -5,8 +5,7 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Params } from '@angular/router';
import { Query } from '@app/shared/internal';
import { Query, QueryParams } from '@app/shared/internal';
import { equalsQuery, QueryFullTextSynchronizer, QuerySynchronizer } from './query';
describe('equalsQuery', () => {
@ -59,103 +58,94 @@ describe('equalsQuery', () => {
describe('QueryFullTextSynchronizer', () => {
const synchronizer = new QueryFullTextSynchronizer();
it('should write full text to route', () => {
const params: Params = {};
it('should parse from state', () => {
const value = { fullText: 'my-query' };
synchronizer.writeValuesToRoute(value, params);
const query = synchronizer.parseFromState({ query: value });
expect(params).toEqual({ query: 'my-query' });
expect(query).toEqual({ query: 'my-query' });
});
it('Should write undefined when not a query', () => {
const params: Params = {};
it('should parse from state as undefined when not a query', () => {
const value = 123;
synchronizer.writeValuesToRoute(value, params);
const query = synchronizer.parseFromState({ query: value });
expect(params).toEqual({ query: undefined });
expect(query).toBeUndefined();
});
it('Should write undefined query has no full text', () => {
const params: Params = {};
it('should parse from state as undefined when no full text', () => {
const value = { fullText: undefined };
const query = synchronizer.parseFromState({ query: value });
expect(query).toBeUndefined();
});
it('should parse from state as undefined when empty full text', () => {
const value = { fullText: '' };
synchronizer.writeValuesToRoute(value, params);
const query = synchronizer.parseFromState({ query: value });
expect(params).toEqual({ query: undefined });
expect(query).toBeUndefined();
});
it('should get query from route', () => {
const params: Params = {
query: 'my-query'
};
const params: QueryParams = { query: 'my-query' };
const value = synchronizer.parseValuesFromRoute(params);
const value = synchronizer.parseFromRoute(params);
expect(value).toEqual({ query: { fullText: 'my-query' } });
});
it('should get query as undefined from route', () => {
const params: Params = {};
const params: QueryParams = {};
const value = synchronizer.parseValuesFromRoute(params);
const value = synchronizer.parseFromRoute(params);
expect(value).toEqual({ query: undefined });
expect(value).toBeUndefined();
});
});
describe('QuerySynchronizer', () => {
const synchronizer = new QuerySynchronizer();
it('should write query to route', () => {
const params: Params = {};
it('should parse from state', () => {
const value = { filter: 'my-filter' };
synchronizer.writeValuesToRoute(value, params);
const query = synchronizer.parseFromState({ query: value });
expect(params).toEqual({ query: '{"filter":"my-filter","sort":[]}' });
expect(query).toEqual({ query: '{"filter":"my-filter","sort":[]}' });
});
it('Should write undefined when not a query', () => {
const params: Params = {};
it('should parse from state as undefined when not a query', () => {
const value = 123;
synchronizer.writeValuesToRoute(value, params);
const query = synchronizer.parseFromState({ query: value });
expect(params).toEqual({ query: undefined });
expect(query).toBeUndefined();
});
it('should get query from route', () => {
const params: Params = {
query: '{"filter":"my-filter"}'
};
const params: QueryParams = { query: '{"filter":"my-filter"}' };
const value = synchronizer.parseValuesFromRoute(params) as any;
const value = synchronizer.parseFromRoute(params) as any;
expect(value).toEqual({ query: { filter: 'my-filter' } });
});
it('should get query full text from route', () => {
const params: Params = {
query: 'my-query'
};
const params: QueryParams = { query: 'my-query' };
const value = synchronizer.parseValuesFromRoute(params);
const value = synchronizer.parseFromRoute(params);
expect(value).toEqual({ query: { fullText: 'my-query' } });
});
it('should get query as undefined from route', () => {
const params: Params = {};
const params: QueryParams = {};
const value = synchronizer.parseValuesFromRoute(params);
const value = synchronizer.parseFromRoute(params);
expect(value).toEqual({ query: undefined });
expect(value).toBeUndefined();
});
});

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

@ -7,8 +7,7 @@
// tslint:disable: readonly-array
import { Params } from '@angular/router';
import { RouteSynchronizer, Types } from '@app/framework';
import { QueryParams, RouteSynchronizer, Types } from '@app/framework';
import { StatusInfo } from './../services/contents.service';
import { LanguageDto } from './../services/languages.service';
import { MetaFields, SchemaDetailsDto } from './../services/schemas.service';
@ -122,21 +121,21 @@ const DEFAULT_QUERY = {
export class QueryFullTextSynchronizer implements RouteSynchronizer {
public static readonly INSTANCE = new QueryFullTextSynchronizer();
public parseValuesFromRoute(params: Params) {
public readonly keys = ['query'];
public parseFromRoute(params: QueryParams) {
const query = params['query'];
if (Types.isString(query)) {
return { query: { fullText: query } };
}
return { query: undefined };
}
public writeValuesToRoute(state: any, params: Params) {
params['query'] = undefined;
public parseFromState(state: any) {
const value = state['query'];
if (Types.isObject(state) && Types.isString(state.fullText) && state.fullText.length > 0) {
params['query'] = state.fullText;
if (Types.isObject(value) && Types.isString(value.fullText) && value.fullText.length > 0) {
return { query: value.fullText };
}
}
}
@ -144,21 +143,21 @@ export class QueryFullTextSynchronizer implements RouteSynchronizer {
export class QuerySynchronizer implements RouteSynchronizer {
public static readonly INSTANCE = new QuerySynchronizer();
public parseValuesFromRoute(params: Params) {
public readonly keys = ['query'];
public parseFromRoute(params: QueryParams) {
const query = params['query'];
if (Types.isString(query)) {
return { query: deserializeQuery(query) };
}
return { query: undefined };
}
public writeValuesToRoute(state: any, params: Params) {
params['query'] = undefined;
public parseFromState(state: any) {
const value = state['query'];
if (Types.isObject(state)) {
params['query'] = serializeQuery(state);
if (Types.isObject(value)) {
return { query: serializeQuery(value) };
}
}
}

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

@ -31,7 +31,7 @@ describe('RuleEventsState', () => {
dialogs = Mock.ofType<DialogService>();
rulesService = Mock.ofType<RulesService>();
rulesService.setup(x => x.getEvents(app, 10, 0, undefined))
rulesService.setup(x => x.getEvents(app, 30, 0, undefined))
.returns(() => of(new RuleEventsDto(200, oldRuleEvents)));
ruleEventsState = new RuleEventsState(appsState.object, dialogs.object, rulesService.object);
@ -48,7 +48,7 @@ describe('RuleEventsState', () => {
});
it('should reset loading when loading failed', () => {
rulesService.setup(x => x.getEvents(app, 10, 0, undefined))
rulesService.setup(x => x.getEvents(app, 30, 0, undefined))
.returns(() => throwError('error'));
ruleEventsState.load().pipe(onErrorResumeNext()).subscribe();
@ -65,30 +65,30 @@ describe('RuleEventsState', () => {
});
it('should load with new pagination when paging', () => {
rulesService.setup(x => x.getEvents(app, 10, 10, undefined))
rulesService.setup(x => x.getEvents(app, 30, 30, undefined))
.returns(() => of(new RuleEventsDto(200, [])));
ruleEventsState.page({ page: 1, pageSize: 10 }).subscribe();
ruleEventsState.page({ page: 1, pageSize: 30 }).subscribe();
expect().nothing();
rulesService.verify(x => x.getEvents(app, 10, 10, undefined), Times.once());
rulesService.verify(x => x.getEvents(app, 10, 0, undefined), Times.once());
rulesService.verify(x => x.getEvents(app, 30, 30, undefined), Times.once());
rulesService.verify(x => x.getEvents(app, 30, 0, undefined), Times.once());
});
it('should load with rule id when filtered', () => {
rulesService.setup(x => x.getEvents(app, 10, 0, '12'))
rulesService.setup(x => x.getEvents(app, 30, 0, '12'))
.returns(() => of(new RuleEventsDto(200, [])));
ruleEventsState.filterByRule('12').subscribe();
expect().nothing();
rulesService.verify(x => x.getEvents(app, 10, 0, '12'), Times.once());
rulesService.verify(x => x.getEvents(app, 30, 0, '12'), Times.once());
});
it('should not load again when rule id has not changed', () => {
rulesService.setup(x => x.getEvents(app, 10, 0, '12'))
rulesService.setup(x => x.getEvents(app, 30, 0, '12'))
.returns(() => of(new RuleEventsDto(200, [])));
ruleEventsState.filterByRule('12').subscribe();
@ -96,7 +96,7 @@ describe('RuleEventsState', () => {
expect().nothing();
rulesService.verify(x => x.getEvents(app, 10, 0, '12'), Times.once());
rulesService.verify(x => x.getEvents(app, 30, 0, '12'), Times.once());
});
it('should call service when enqueuing event', () => {

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

@ -6,7 +6,7 @@
*/
import { Injectable } from '@angular/core';
import { DialogService, getPagingInfo, ListState, Router2State, shareSubscribed, State } from '@app/framework';
import { DialogService, getPagingInfo, ListState, shareSubscribed, State } from '@app/framework';
import { EMPTY, Observable } from 'rxjs';
import { finalize, tap } from 'rxjs/operators';
import { RuleEventDto, RulesService } from './../services/rules.service';
@ -45,22 +45,14 @@ export class RuleEventsState extends State<Snapshot> {
super({
ruleEvents: [],
page: 0,
pageSize: 10,
pageSize: 30,
total: 0
});
}
public loadAndListen(route: Router2State) {
route.mapTo(this)
.withPaging('ruleEvents', 30)
.withString('ruleId', 'ruleId')
.whenSynced(() => this.loadInternal(false))
.build();
}
public load(isReload = false): Observable<any> {
public load(isReload = false, update: Partial<Snapshot> = {}): Observable<any> {
if (!isReload) {
this.resetState();
this.resetState({ ruleId: this.snapshot.ruleId, ...update });
}
return this.loadInternal(isReload);

11
frontend/app/shared/state/schemas.state.ts

@ -6,7 +6,7 @@
*/
import { Injectable } from '@angular/core';
import { compareStrings, defined, DialogService, shareMapSubscribed, shareSubscribed, State, Types, Version } from '@app/framework';
import { compareStrings, DialogService, shareMapSubscribed, shareSubscribed, State, Types, Version } from '@app/framework';
import { EMPTY, Observable, of } from 'rxjs';
import { catchError, finalize, tap } from 'rxjs/operators';
import { AddFieldDto, CreateSchemaDto, FieldDto, FieldRule, NestedFieldDto, RootFieldDto, SchemaDetailsDto, SchemaDto, SchemasService, UpdateFieldDto, UpdateSchemaDto, UpdateUIFields } from './../services/schemas.service';
@ -37,20 +37,13 @@ interface Snapshot {
export type SchemasList = ReadonlyArray<SchemaDto>;
export type SchemaCategory = { name: string; schemas: SchemasList; upper: string; };
function sameSchema(lhs: SchemaDetailsDto | null, rhs?: SchemaDetailsDto | null): boolean {
return lhs === rhs || (!!lhs && !!rhs && lhs.id === rhs.id && lhs.version === rhs.version);
}
@Injectable()
export class SchemasState extends State<Snapshot> {
public categoryNames =
this.project(x => x.categories);
public selectedSchemaOrNull =
this.project(x => x.selectedSchema, sameSchema);
public selectedSchema =
this.selectedSchemaOrNull.pipe(defined());
this.project(x => x.selectedSchema);
public schemas =
this.project(x => x.schemas);

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

@ -6,7 +6,7 @@
*/
import { Injectable } from '@angular/core';
import { hasAnyLink, State, Types } from '@app/framework';
import { defined, hasAnyLink, State, Types } from '@app/framework';
import { distinctUntilChanged, filter, map } from 'rxjs/operators';
import { UIService, UISettingsDto } from './../services/ui.service';
import { UsersService } from './../services/users.service';
@ -95,9 +95,10 @@ export class UIState extends State<Snapshot> {
this.loadResources();
this.loadCommon();
appsState.selectedApp.subscribe(app => {
this.load(app.name);
});
appsState.selectedApp.pipe(defined())
.subscribe(app => {
this.load(app.name);
});
}
private load(app: string) {

20
frontend/app/shell/pages/app/app-area.component.html

@ -1,9 +1,11 @@
<sqx-title [message]="appsState.appDisplayName"></sqx-title>
<div class="sidebar">
<sqx-left-menu></sqx-left-menu>
</div>
<div sqxPanelContainer class="panel-container">
<router-outlet></router-outlet>
</div>
<ng-container *ngIf="selectedApp | async; let app">
<sqx-title [message]="app.displayName"></sqx-title>
<div class="sidebar">
<sqx-left-menu [app]="app"></sqx-left-menu>
</div>
<div sqxPanelContainer class="panel-container">
<router-outlet></router-outlet>
</div>
</ng-container>

6
frontend/app/shell/pages/app/app-area.component.ts

@ -6,7 +6,7 @@
*/
import { Component } from '@angular/core';
import { AppsState } from '@app/shared';
import { AppsState, defined } from '@app/shared';
@Component({
selector: 'sqx-app-area',
@ -14,8 +14,10 @@ import { AppsState } from '@app/shared';
templateUrl: './app-area.component.html'
})
export class AppAreaComponent {
public selectedApp = this.appsState.selectedApp.pipe(defined());
constructor(
public readonly appsState: AppsState
private readonly appsState: AppsState
) {
}
}

2
frontend/app/shell/pages/app/left-menu.component.html

@ -1,4 +1,4 @@
<ul class="nav flex-column" *ngIf="appsState.selectedApp | async; let app">
<ul class="nav flex-column">
<li class="nav-item" *ngIf="!isSchemasHidden(app) && app.canReadSchemas">
<a class="nav-link" routerLink="schemas" routerLinkActive="active">
<i class="nav-icon icon-schemas"></i> <div class="nav-text">{{ 'common.schemas' | sqxTranslate }}</div>

10
frontend/app/shell/pages/app/left-menu.component.ts

@ -5,8 +5,8 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { AppDto, AppsState, Settings } from '@app/shared';
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { AppDto, Settings } from '@app/shared';
@Component({
selector: 'sqx-left-menu',
@ -15,10 +15,8 @@ import { AppDto, AppsState, Settings } from '@app/shared';
changeDetection: ChangeDetectionStrategy.OnPush
})
export class LeftMenuComponent {
constructor(
public readonly appsState: AppsState
) {
}
@Input()
public app: AppDto;
public isAssetsHidden(app: AppDto) {
return app.roleProperties[Settings.AppProperties.HIDE_ASSETS] === true;

4
frontend/app/shell/pages/internal/apps-menu.component.html

@ -1,7 +1,7 @@
<ul class="nav navbar-nav align-items-center">
<li class="nav-item dropdown">
<span class="nav-link dropdown-toggle" id="app-name" (click)="appsMenu.toggle()" #button>
<ng-container *ngIf="appsState.selectedAppOrNull | async; let app; else noApp">
<ng-container *ngIf="appsState.selectedApp | async; let app; else noApp">
{{app.displayName}}
</ng-container>
@ -40,7 +40,7 @@
</ng-container>
</li>
<ng-container *ngIf="appsState.selectedAppOrNull | async; let app">
<ng-container *ngIf="appsState.selectedApp | async; let app">
<li class="nav-item" *ngIf="app.planUpgrade && app.planName">
<div class="btn-group app-upgrade">
<button type="button" class="btn btn-primary btn-plan">

1
frontend/app/shell/pages/internal/profile-menu.component.ts

@ -23,7 +23,6 @@ interface State {
// True when the submenu should be open.
showSubmenu: boolean;
}
@Component({

2
frontend/app/shell/pages/internal/search-menu.component.html

@ -1,4 +1,4 @@
<ng-container *ngIf="searchSource.selectedAppOrNull | async">
<ng-container *ngIf="searchSource.selectedApp | async">
<div class="search-container ml-4">
<sqx-autocomplete [source]="searchSource" inputName="searchMenu" placeholder="{{ 'search.quickNavPlaceholder' | sqxTranslate }}" icon="search" #searchControl dropdownWidth="30rem"
(ngModelChange)="selectResult($event)">

2
frontend/app/shell/pages/internal/search-menu.component.ts

@ -12,7 +12,7 @@ import { Observable, of } from 'rxjs';
@Injectable()
export class SearchSource implements AutocompleteSource {
public selectedAppOrNull = this.appsState.selectedAppOrNull;
public selectedApp = this.appsState.selectedApp;
constructor(
private readonly appsState: AppsState,

Loading…
Cancel
Save