Browse Source

Feature/nav improvement (#611)

* feature improvement

* Routing improvements.

* Run all tests.

* Many simplifications.

* Binding fix.

* Grid fix.

* Build fixes.
pull/613/head
Sebastian Stehle 5 years ago
committed by GitHub
parent
commit
be0f81cf51
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  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. 34
      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. 6
      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. 119
      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. 257
      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. 5
      frontend/app/shared/state/ui.state.ts
  68. 6
      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 { Component } from '@angular/core';
import { UIState } from '@app/shared';
@Component({ @Component({
selector: 'sqx-cluster-area', selector: 'sqx-cluster-area',
@ -14,8 +13,4 @@ import { UIState } from '@app/shared';
templateUrl: './cluster-page.component.html' templateUrl: './cluster-page.component.html'
}) })
export class ClusterPageComponent { 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; public eventConsumer: EventConsumerDto;
constructor( 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> <div content>
<table class="table table-items table-fixed" *ngIf="usersState.users | async; let users" [sqxSyncWidth]="header"> <table class="table table-items table-fixed" *ngIf="usersState.users | async; let users" [sqxSyncWidth]="header">
<tbody *ngFor="let user of users; trackBy: trackByUser" <tbody *ngFor="let user of users; trackBy: trackByUser" [sqxUser]="user"></tbody>
[sqxUser]="user">
</tbody>
</table> </table>
</div> </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() { 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() { public reload() {
@ -44,7 +51,7 @@ export class UsersPageComponent extends ResourceOwner implements OnInit {
this.usersState.search(this.usersFilter.value); this.usersState.search(this.usersFilter.value);
} }
public trackByUser(_ndex: number, user: UserDto) { public trackByUser(_index: number, user: UserDto) {
return user.id; 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 { of, throwError } from 'rxjs';
import { onErrorResumeNext } from 'rxjs/operators'; import { onErrorResumeNext } from 'rxjs/operators';
import { IMock, It, Mock, Times } from 'typemoq'; import { IMock, It, Mock, Times } from 'typemoq';
import { TestValues } from './../../../shared/state/_test-helpers';
import { createUser } from './../services/users.service.spec'; import { createUser } from './../services/users.service.spec';
import { UsersState } from './users.state'; import { UsersState } from './users.state';
describe('UsersState', () => { describe('UsersState', () => {
const { buildDummyStateSynchronizer } = TestValues;
const user1 = createUser(1); const user1 = createUser(1);
const user2 = createUser(2); const user2 = createUser(2);
@ -110,20 +107,6 @@ describe('UsersState', () => {
expect(usersState.snapshot.query).toEqual('my-query'); 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', () => { describe('Updates', () => {

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

@ -7,7 +7,7 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import '@app/framework/utils/rxjs-extensions'; import '@app/framework/utils/rxjs-extensions';
import { DialogService, getPagingInfo, ListState, shareSubscribed, State, StateSynchronizer } from '@app/shared'; import { DialogService, getPagingInfo, ListState, shareSubscribed, State } from '@app/shared';
import { Observable, of } from 'rxjs'; import { Observable, of } from 'rxjs';
import { catchError, finalize, tap } from 'rxjs/operators'; import { catchError, finalize, tap } from 'rxjs/operators';
import { CreateUserDto, UpdateUserDto, UserDto, UsersService } from './../services/users.service'; import { CreateUserDto, UpdateUserDto, UserDto, UsersService } from './../services/users.service';
@ -83,18 +83,9 @@ export class UsersState extends State<Snapshot> {
return this.usersService.getUser(id).pipe(catchError(() => of(null))); return this.usersService.getUser(id).pipe(catchError(() => of(null)));
} }
public loadAndListen(synchronizer: StateSynchronizer) { public load(isReload = false, update: Partial<Snapshot> = {}): Observable<any> {
synchronizer.mapTo(this)
.keep('selectedUser')
.withPaging('users', 10)
.withString('query', 'q')
.whenSynced(() => this.loadInternal(false))
.build();
}
public load(isReload = false): Observable<any> {
if (!isReload) { if (!isReload) {
this.resetState({ selectedUser: this.snapshot.selectedUser }); this.resetState({ selectedUser: this.snapshot.selectedUser, ...update });
} }
return this.loadInternal(isReload); 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) { 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' templateUrl: './assets-filters-page.component.html'
}) })
export class AssetsFiltersPageComponent { export class AssetsFiltersPageComponent {
public assetsQueries = new Queries(this.uiState, 'assets'); public assetsQueries: Queries;
constructor( constructor(uiState: UIState,
public readonly assetsState: AssetsState, public readonly assetsState: AssetsState
private readonly uiState: UIState
) { ) {
this.assetsQueries = new Queries(uiState, 'assets');
} }
public search(query: Query) { 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 { 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'; import { Settings } from '@app/shared/state/settings';
@Component({ @Component({
@ -36,7 +36,16 @@ export class AssetsPageComponent extends ResourceOwner implements OnInit {
} }
public ngOnInit() { 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() { public reload() {

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

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

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

@ -18,9 +18,21 @@
<ng-container *ngIf="content"> <ng-container *ngIf="content">
<sqx-title message="i18n:contents.editPageTitle"></sqx-title> <sqx-title message="i18n:contents.editPageTitle"></sqx-title>
<ul class="nav nav-tabs2"> <ul class="nav nav-tabs2" *ngIf="contentTab | async; let tab">
<li class="nav-item" *ngFor="let tab of selectableTabs"> <li class="nav-item">
<a class="nav-link" [class.active]="selectedTab === tab" (click)="selectTab(tab)">{{tab | sqxTranslate}}</a> <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> </li>
</ul> </ul>
</ng-container> </ng-container>
@ -28,7 +40,7 @@
<ng-container menu> <ng-container menu>
<ng-container *ngIf="content; else noContent"> <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-notifo topic="apps/{{contentsState.appId}}/schemas/{{schema?.name}}/contents/{{content.id}}"></sqx-notifo>
<sqx-preview-button [schema]="schema" [content]="content"></sqx-preview-button> <sqx-preview-button [schema]="schema" [content]="content"></sqx-preview-button>
@ -88,8 +100,18 @@
<ng-container content> <ng-container content>
<ng-container *ngIf="content; else noContentEditor"> <ng-container *ngIf="content; else noContentEditor">
<ng-container [ngSwitch]="selectedTab"> <ng-container [ngSwitch]="contentTab | async">
<ng-container *ngSwitchCase="'i18n:contents.contentTab.editor'"> <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 <sqx-content-editor
[(language)]="language" [(language)]="language"
[contentForm]="contentForm" [contentForm]="contentForm"
@ -99,16 +121,6 @@
[schema]="schema"> [schema]="schema">
</sqx-content-editor> </sqx-content-editor>
</ng-container> </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>
</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 { Component, OnInit, ViewChild } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { ApiUrlConfig, AppLanguageDto, AppsState, AuthService, AutoSaveKey, AutoSaveService, CanComponentDeactivate, ContentDto, ContentsState, 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 { Observable, of } from 'rxjs';
import { filter, tap } from 'rxjs/operators'; import { filter, map, tap } from 'rxjs/operators';
import { ContentReferencesComponent } from './references/content-references.component'; import { ContentReferencesComponent } from './references/content-references.component';
const TABS: ReadonlyArray<string> = [
'i18n:contents.contentTab.editor',
'i18n:contents.contentTab.references',
'i18n:contents.contentTab.referencing'
];
@Component({ @Component({
selector: 'sqx-content-page', selector: 'sqx-content-page',
styleUrls: ['./content-page.component.scss'], styleUrls: ['./content-page.component.scss'],
@ -39,14 +33,12 @@ export class ContentPageComponent extends ResourceOwner implements CanComponentD
public formContext: any; public formContext: any;
public contentTab = this.route.queryParams.pipe(map(x => x['tab'] || 'editor'));
public content?: ContentDto | null; public content?: ContentDto | null;
public contentVersion: Version | null; public contentVersion: Version | null;
public contentForm: EditContentForm; public contentForm: EditContentForm;
public contentFormCompare: EditContentForm | null = null; public contentFormCompare: EditContentForm | null = null;
public selectableTabs = TABS;
public selectedTab = this.selectableTabs[0];
public dropdown = new ModalModel(); public dropdown = new ModalModel();
public language: AppLanguageDto; public language: AppLanguageDto;
@ -66,8 +58,8 @@ export class ContentPageComponent extends ResourceOwner implements CanComponentD
this.formContext = { this.formContext = {
apiUrl: apiUrl.buildUrl('api'), apiUrl: apiUrl.buildUrl('api'),
appId: appsState.snapshot.selectedApp!.id, appId: contentsState.appId,
appName: appsState.snapshot.selectedApp!.name, appName: appsState.appName,
user: authService.user user: authService.user
}; };
} }
@ -76,14 +68,19 @@ export class ContentPageComponent extends ResourceOwner implements CanComponentD
this.contentsState.loadIfNotLoaded(); this.contentsState.loadIfNotLoaded();
this.own( this.own(
this.languagesState.languagesDtos this.languagesState.isoMasterLanguage
.subscribe(language => {
this.language = language;
}));
this.own(
this.languagesState.isoLanguages
.subscribe(languages => { .subscribe(languages => {
this.languages = languages; this.languages = languages;
this.language = this.languages.find(x => x.isMaster)!;
})); }));
this.own( this.own(
this.schemasState.selectedSchema this.schemasState.selectedSchema.pipe(defined())
.subscribe(schema => { .subscribe(schema => {
this.schema = schema; this.schema = schema;
@ -139,10 +136,6 @@ export class ContentPageComponent extends ResourceOwner implements CanComponentD
); );
} }
public selectTab(tab: string) {
this.selectedTab = tab;
}
public validate() { public validate() {
this.references?.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. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { Component, Input, OnChanges, SimpleChanges } from '@angular/core'; import { ChangeDetectionStrategy, Component, Input, OnChanges, SimpleChanges } from '@angular/core';
import { AppLanguageDto, ContentDto, ManualContentsState } from '@app/shared'; import { AppLanguageDto, ContentDto, ManualContentsState, QuerySynchronizer, Router2State } from '@app/shared';
@Component({ @Component({
selector: 'sqx-content-references', selector: 'sqx-content-references',
styleUrls: ['./content-references.component.scss'], styleUrls: ['./content-references.component.scss'],
templateUrl: './content-references.component.html', templateUrl: './content-references.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [ providers: [
ManualContentsState Router2State, ManualContentsState
] ]
}) })
export class ContentReferencesComponent implements OnChanges { export class ContentReferencesComponent implements OnChanges {
@ -27,6 +28,7 @@ export class ContentReferencesComponent implements OnChanges {
public mode: 'references' | 'referencing' = 'references'; public mode: 'references' | 'referencing' = 'references';
constructor( constructor(
public readonly contentsRoute: Router2State,
public readonly contentsState: ManualContentsState public readonly contentsState: ManualContentsState
) { ) {
} }
@ -35,11 +37,19 @@ export class ContentReferencesComponent implements OnChanges {
if (changes['content'] || changes['mode']) { if (changes['content'] || changes['mode']) {
this.contentsState.schema = { name: this.content.schemaName }; 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') { if (this.mode === 'references') {
this.contentsState.loadReference(this.content.id); this.contentsState.loadReference(this.content.id, initial);
} else { } else {
this.contentsState.loadReferencing(this.content.id); this.contentsState.loadReferencing(this.content.id, initial);
} }
this.contentsRoute.listen();
} }
} }

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

@ -4,10 +4,11 @@
</ng-container> </ng-container>
<ng-container content> <ng-container content>
<ng-container *ngIf="schemaQueries | async; let queries">
<sqx-query-list <sqx-query-list
[types]="'common.contents' | sqxTranslate" [types]="'common.contents' | sqxTranslate"
[queryUsed]="contentsState.query | async" [queryUsed]="contentsState.query | async"
[queries]="schemaQueries.defaultQueries" [queries]="queries.defaultQueries"
(search)="search($event)"> (search)="search($event)">
</sqx-query-list> </sqx-query-list>
@ -29,8 +30,9 @@
<sqx-shared-queries <sqx-shared-queries
[types]="'common.contents' | sqxTranslate" [types]="'common.contents' | sqxTranslate"
[queryUsed]="contentsState.query | async" [queryUsed]="contentsState.query | async"
[queries]="schemaQueries" [queries]="queries"
(search)="search($event)"> (search)="search($event)">
</sqx-shared-queries> </sqx-shared-queries>
</ng-container> </ng-container>
</ng-container>
</sqx-panel> </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. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { Component, OnInit } from '@angular/core'; import { Component } from '@angular/core';
import { ContentsState, Queries, Query, ResourceOwner, SchemasState, UIState } from '@app/shared'; import { ContentsState, defined, Queries, Query, SchemasState, UIState } from '@app/shared';
import { map } from 'rxjs/operators';
@Component({ @Component({
selector: 'sqx-contents-filters-page', selector: 'sqx-contents-filters-page',
styleUrls: ['./contents-filters-page.component.scss'], styleUrls: ['./contents-filters-page.component.scss'],
templateUrl: './contents-filters-page.component.html' templateUrl: './contents-filters-page.component.html'
}) })
export class ContentsFiltersPageComponent extends ResourceOwner implements OnInit { export class ContentsFiltersPageComponent {
public schemaQueries: Queries; public schemaQueries =
this.schemasState.selectedSchema.pipe(
defined(),
map(schema => new Queries(this.uiState, `schemas.${schema.name}`)
));
constructor( constructor(
public readonly contentsState: ContentsState, public readonly contentsState: ContentsState,
private readonly schemasState: SchemasState, private readonly schemasState: SchemasState,
private readonly uiState: UIState 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) { 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 }}" <sqx-search-form formClass="form" placeholder="{{ 'contents.searchPlaceholder' | sqxTranslate }}"
(queryChange)="search($event)" (queryChange)="search($event)"
[query]="contentsState.query | async" [query]="contentsState.query | async"
[queries]="queries" [queries]="queries | async"
[queryModel]="queryModel" [queryModel]="queryModel | async"
[language]="languageMaster" enableShortcut="true"> [language]="languagesState.isoMasterLanguage | async"
enableShortcut="true">
</sqx-search-form> </sqx-search-form>
</div> </div>
<div class="col-auto pl-1" *ngIf="languages.length > 1"> <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>
<div class="col-auto pl-1"> <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"> <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"> <div class="selection" *ngIf="selectionCount > 0">
{{ 'contents.selectionCount' | sqxTranslate: { count: selectionCount } }}&nbsp;&nbsp; {{ '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" <sqx-content-status layout="text"
[status]="status" [status]="status"
[statusColor]="nextStatuses[status]"> [statusColor]="selectionStatuses[status]">
</sqx-content-status> </sqx-content-status>
</button> </button>
@ -115,8 +116,8 @@
[selected]="isItemSelected(content)" [selected]="isItemSelected(content)"
(selectedChange)="selectItem(content, $event)" (selectedChange)="selectItem(content, $event)"
(statusChange)="changeStatus(content, $event)" (statusChange)="changeStatus(content, $event)"
[cloneable]="contentsState.snapshot.canCreate"
(clone)="clone(content)" (clone)="clone(content)"
[canClone]="contentsState.snapshot.canCreate"
[language]="language" [language]="language"
[link]="[content.id, 'history']" [link]="[content.id, 'history']"
[listFields]="listFields"> [listFields]="listFields">

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

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

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

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

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

@ -37,7 +37,7 @@
[statusColor]="info.color"> [statusColor]="info.color">
</sqx-content-status> </sqx-content-status>
</a> </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 }} {{ 'common.clone' | sqxTranslate }}
</a> </a>

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

@ -43,7 +43,7 @@ export class ContentComponent implements OnChanges {
public listFields: ReadonlyArray<TableField>; public listFields: ReadonlyArray<TableField>;
@Input() @Input()
public canClone: boolean; public cloneable: boolean;
@Input() @Input()
public link: any = null; 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="row no-gutters">
<div class="col-auto"> <div class="col-auto">
<div *ngIf="schema && languages.length > 1"> <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>
</div> </div>
@ -38,10 +38,10 @@
<form [formGroup]="contentForm.form" (ngSubmit)="saveAndPublish()"> <form [formGroup]="contentForm.form" (ngSubmit)="saveAndPublish()">
<sqx-content-section *ngFor="let section of contentForm.sections" <sqx-content-section *ngFor="let section of contentForm.sections"
[(language)]="language" [(language)]="language"
[isCompact]="true"
[form]="contentForm" [form]="contentForm"
[formContext]="contentFormContext" [formContext]="formContext"
[formSection]="section" [formSection]="section"
[isCompact]="true"
[languages]="languages" [languages]="languages"
[schema]="schema"> [schema]="schema">
</sqx-content-section> </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 { 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({ @Component({
selector: 'sqx-content-creator', selector: 'sqx-content-creator',
@ -29,22 +29,20 @@ export class ContentCreatorComponent extends ResourceOwner implements OnInit {
@Input() @Input()
public languages: ReadonlyArray<AppLanguageDto>; public languages: ReadonlyArray<AppLanguageDto>;
@Input()
public formContext: any;
public schema: SchemaDetailsDto; public schema: SchemaDetailsDto;
public schemas: ReadonlyArray<SchemaDto> = []; public schemas: ReadonlyArray<SchemaDto> = [];
public contentFormContext: any;
public contentForm: EditContentForm; public contentForm: EditContentForm;
constructor(authService: AuthService, constructor(
public readonly appsState: AppsState, private readonly contentsState: ManualContentsState,
public readonly apiUrl: ApiUrlConfig, private readonly schemasState: SchemasState,
public readonly contentsState: ManualContentsState,
public readonly schemasState: SchemasState,
private readonly changeDetector: ChangeDetectorRef private readonly changeDetector: ChangeDetectorRef
) { ) {
super(); super();
this.contentFormContext = { user: authService.user, apiUrl: apiUrl.buildUrl('api') };
} }
public ngOnInit() { public ngOnInit() {
@ -68,7 +66,7 @@ export class ContentCreatorComponent extends ResourceOwner implements OnInit {
this.schema = schema; this.schema = schema;
this.contentsState.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(); this.changeDetector.markForCheck();
} }
@ -108,7 +106,7 @@ export class ContentCreatorComponent extends ResourceOwner implements OnInit {
if (publish) { if (publish) {
return this.schema.canContentsCreateAndPublish; return this.schema.canContentsCreateAndPublish;
} else { } else {
return this.schema.canContentsCreateAndPublish; return this.schema.canContentsCreate;
} }
} }
@ -119,8 +117,4 @@ export class ContentCreatorComponent extends ResourceOwner implements OnInit {
public emitSelect(content: ContentDto) { public emitSelect(content: ContentDto) {
this.select.emit([content]); 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>
<div class="col-auto pl-1" *ngIf="languages.length > 1"> <div class="col-auto pl-1" *ngIf="languages.length > 1">
<sqx-language-selector class="languages-buttons" <sqx-language-selector class="languages-buttons" [(selectedLanguage)]="language" [languages]="languages"></sqx-language-selector>
(selectedLanguageChange)="selectLanguage($event)" [languages]="languages">
</sqx-language-selector>
</div> </div>
</ng-container> </ng-container>
</div> </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"> <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" <tbody *ngFor="let content of contents; trackBy: trackByContent"
[sqxContentSelectorItem]="content" [sqxContentSelectorItem]="content"
[language]="language"
[schema]="schema" [schema]="schema"
[selectable]="!isItemAlreadySelected(content)" [selectable]="!isItemAlreadySelected(content)"
[selected]="isItemSelected(content)" [selected]="isItemSelected(content)"
(selectedChange)="selectContent(content)" (selectedChange)="selectContent(content)">
[language]="language">
</tbody> </tbody>
</table> </table>
</div> </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.updateModel();
})); }));
this.schemas = this.schemasState.snapshot.schemas; this.schemas = this.schemasState.snapshot.schemas.filter(x => x.canReadContents);
if (this.schemaIds && this.schemaIds.length > 0) { if (this.schemaIds && this.schemaIds.length > 0) {
this.schemas = this.schemas.filter(x => this.schemaIds.indexOf(x.id) >= 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)); this.select.emit(Object.values(this.selectedItems));
} }
public selectLanguage(language: LanguageDto) {
this.language = language;
}
public selectAll(isSelected: boolean) { public selectAll(isSelected: boolean) {
this.selectedItems = {}; this.selectedItems = {};

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

@ -37,6 +37,7 @@
<ng-container *sqxModal="contentCreatorDialog"> <ng-container *sqxModal="contentCreatorDialog">
<sqx-content-creator <sqx-content-creator
(select)="select($event)" (select)="select($event)"
[formContext]="formContext"
[language]="language" [language]="language"
[languages]="languages" [languages]="languages"
[schemaIds]="schemaIds"> [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() @Input()
public languages: ReadonlyArray<AppLanguageDto>; public languages: ReadonlyArray<AppLanguageDto>;
@Input()
public formContext: any;
@Input() @Input()
public allowDuplicates = true; 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> <sqx-title message="i18n:dashboard.pageTitle"></sqx-title>
<ng-container *ngIf="appsState.selectedApp | async; let app"> <ng-container *ngIf="selectedApp | async; let app">
<div class="dashboard" @fade=""> <div class="dashboard" @fade>
<div class="dashboard-summary" *ngIf="!isScrolled" @fade=""> <div class="dashboard-summary" *ngIf="!isScrolled" @fade>
<h1 class="dashboard-title">{{ 'dashboard.welcomeTitle' | sqxTranslate: { user: authState.user?.displayName } }}</h1> <h1 class="dashboard-title">{{ 'dashboard.welcomeTitle' | sqxTranslate: { user: user } }}</h1>
<div class="subtext" [innerHTML]="'dashboard.welcomeText' | sqxTranslate: { app: app.displayName } | sqxMarkdown"></div> <div class="subtext" [innerHTML]="'dashboard.welcomeText' | sqxTranslate: { app: app.displayName } | sqxMarkdown"></div>
</div> </div>
<gridster [options]="gridOptions" #grid=""> <gridster [options]="gridOptions" #grid>
<gridster-item [item]="item" *ngFor="let item of gridConfig"> <gridster-item [item]="item" *ngFor="let item of gridConfig">
<ng-container [ngSwitch]="item.type"> <ng-container [ngSwitch]="item.type">
<ng-container *ngSwitchCase="'schemas'"> <ng-container *ngSwitchCase="'schemas'">
@ -66,8 +66,10 @@
</gridster-item> </gridster-item>
</gridster> </gridster>
<div class="dashboard-settings"> <div class="dashboard-settings" *ngIf="grid">
<sqx-dashboard-config [app]="app" [needsAttention]="isScrolled" [config]="gridConfig" (configChange)="changeConfig($event)"> <sqx-dashboard-config [app]="app" [needsAttention]="isScrolled"
[config]="gridConfig"
(configChange)="changeConfig($event)">
</sqx-dashboard-config> </sqx-dashboard-config>
</div> </div>
</div> </div>

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

@ -8,9 +8,8 @@
// tslint:disable: readonly-array // tslint:disable: readonly-array
import { AfterViewInit, Component, NgZone, OnInit, Renderer2, ViewChild } from '@angular/core'; 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 { GridsterComponent, GridsterConfig, GridsterItem, GridType } from 'angular-gridster2';
import { switchMap } from 'rxjs/operators';
@Component({ @Component({
selector: 'sqx-dashboard-page', selector: 'sqx-dashboard-page',
@ -24,6 +23,8 @@ export class DashboardPageComponent extends ResourceOwner implements AfterViewIn
@ViewChild('grid') @ViewChild('grid')
public grid: GridsterComponent; public grid: GridsterComponent;
public selectedApp = this.appsState.selectedApp.pipe(defined());
public isStacked: boolean; public isStacked: boolean;
public storageCurrent: CurrentStorageDto; public storageCurrent: CurrentStorageDto;
@ -36,9 +37,11 @@ export class DashboardPageComponent extends ResourceOwner implements AfterViewIn
public isScrolled = false; public isScrolled = false;
public user = this.authState.user?.displayName;
constructor( constructor(
public readonly appsState: AppsState, private readonly appsState: AppsState,
public readonly authState: AuthService, private readonly authState: AuthService,
private readonly localStore: LocalStoreService, private readonly localStore: LocalStoreService,
private readonly renderer: Renderer2, private readonly renderer: Renderer2,
private readonly usagesService: UsagesService, 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'); const dateFrom = DateTime.today().addDays(-20).toStringFormat('yyyy-MM-dd');
this.own( this.own(
this.appsState.selectedApp.pipe( this.selectedApp.pipe(switchSafe(app => this.usagesService.getTodayStorage(app.name)))
switchMap(app => this.usagesService.getTodayStorage(app.name)))
.subscribe(dto => { .subscribe(dto => {
this.storageCurrent = dto; this.storageCurrent = dto;
})); }));
this.own( this.own(
this.appsState.selectedApp.pipe( this.selectedApp.pipe(switchSafe(app => this.usagesService.getStorageUsages(app.name, dateFrom, dateTo)))
switchMap(app => this.usagesService.getStorageUsages(app.name, dateFrom, dateTo)))
.subscribe(dtos => { .subscribe(dtos => {
this.storageUsage = dtos; this.storageUsage = dtos;
})); }));
this.own( this.own(
this.appsState.selectedApp.pipe( this.selectedApp.pipe(switchSafe(app => this.usagesService.getCallsUsages(app.name, dateFrom, dateTo)))
switchMap(app => this.usagesService.getCallsUsages(app.name, dateFrom, dateTo)))
.subscribe(dto => { .subscribe(dto => {
this.callsUsage = dto; this.callsUsage = dto;
})); }));
@ -100,7 +100,7 @@ export class DashboardPageComponent extends ResourceOwner implements AfterViewIn
public changeConfig(config: GridsterItem[]) { public changeConfig(config: GridsterItem[]) {
this.gridConfig = config; 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() { 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() { 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()"> <form [formGroup]="editForm.form" class="edit-form" (ngSubmit)="save()">
<sqx-field-form <sqx-field-form
[patterns]="patternsState.patterns | async" [patterns]="patternsState.patterns | async"
[languages]="languagesState.languagesDtos | async" [languages]="languagesState.isoLanguages | async"
[field]="field" [field]="field"
[fieldForm]="editForm.form" [fieldForm]="editForm.form"
[isEditable]="true" [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"> <div class="table-items-row-details-tabs clearfix">
<ul class="nav nav-tabs2"> <ul class="nav nav-tabs2">
<li class="nav-item"> <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>
<li class="nav-item" [class.hidden]="!field.properties.isContentField"> <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>
<li class="nav-item" [class.hidden]="!field.properties.isContentField || field.properties.fieldType === 'Array'"> <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> </li>
</ul> </ul>

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

@ -6,7 +6,7 @@
</button> </button>
</div> </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"> <ng-container *ngIf="patternsState.patterns | async; let patterns">
<div <div
cdkDropList cdkDropList

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

@ -1,10 +1,32 @@
<sqx-title [message]="schemasState.schemaName"></sqx-title> <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> <ng-container header>
<ul class="nav nav-tabs2"> <ul class="nav nav-tabs2">
<li class="nav-item" *ngFor="let tab of selectableTabs"> <li class="nav-item">
<a class="nav-link" [class.active]="tab === selectedTab" (click)="selectTab(tab)">{{tab | sqxTranslate}}</a> <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> </li>
</ul> </ul>
</ng-container> </ng-container>
@ -57,26 +79,26 @@
</ng-container> </ng-container>
<ng-container content> <ng-container content>
<ng-container [ngSwitch]="selectedTab"> <ng-container [ngSwitch]="tab">
<ng-container *ngSwitchCase="'i18n:schemas.tabFields'"> <ng-container *ngSwitchCase="'ui'">
<sqx-schema-fields [schema]="schema"></sqx-schema-fields>
</ng-container>
<ng-container *ngSwitchCase="'i18n:schemas.tabUI'">
<sqx-schema-ui-form [schema]="schema"></sqx-schema-ui-form> <sqx-schema-ui-form [schema]="schema"></sqx-schema-ui-form>
</ng-container> </ng-container>
<ng-container *ngSwitchCase="'i18n:schemas.tabScripts'"> <ng-container *ngSwitchCase="'scripts'">
<sqx-schema-scripts-form [schema]="schema"></sqx-schema-scripts-form> <sqx-schema-scripts-form [schema]="schema"></sqx-schema-scripts-form>
</ng-container> </ng-container>
<ng-container *ngSwitchCase="'i18n:schemas.tabJson'"> <ng-container *ngSwitchCase="'json'">
<sqx-schema-export-form [schema]="schema"></sqx-schema-export-form> <sqx-schema-export-form [schema]="schema"></sqx-schema-export-form>
</ng-container> </ng-container>
<ng-container *ngSwitchCase="'i18n:schemas.tabMore'"> <ng-container *ngSwitchCase="'more'">
<div class="cards"> <div class="cards">
<sqx-schema-preview-urls-form [schema]="schema"> </sqx-schema-preview-urls-form> <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-field-rules-form [schema]="schema"> </sqx-schema-field-rules-form>
<sqx-schema-edit-form [schema]="schema"></sqx-schema-edit-form> <sqx-schema-edit-form [schema]="schema"></sqx-schema-edit-form>
</div> </div>
</ng-container> </ng-container>
<ng-container *ngSwitchDefault>
<sqx-schema-fields [schema]="schema"></sqx-schema-fields>
</ng-container>
</ng-container> </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 { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router'; 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'; import { SchemaCloning } from './../messages';
const TABS: ReadonlyArray<string> = [
'i18n:schemas.tabFields',
'i18n:schemas.tabUI',
'i18n:schemas.tabScripts',
'i18n:schemas.tabJson',
'i18n:schemas.tabMore'
];
@Component({ @Component({
selector: 'sqx-schema-page', selector: 'sqx-schema-page',
styleUrls: ['./schema-page.component.scss'], styleUrls: ['./schema-page.component.scss'],
@ -30,9 +23,7 @@ export class SchemaPageComponent extends ResourceOwner implements OnInit {
public readonly exact = { exact: true }; public readonly exact = { exact: true };
public schema: SchemaDetailsDto; public schema: SchemaDetailsDto;
public schemaTab = this.route.queryParams.pipe(map(x => x['tab'] || 'fields'));
public selectableTabs: ReadonlyArray<string> = TABS;
public selectedTab = this.selectableTabs[0];
public editOptionsDropdown = new ModalModel(); public editOptionsDropdown = new ModalModel();
@ -47,7 +38,7 @@ export class SchemaPageComponent extends ResourceOwner implements OnInit {
public ngOnInit() { public ngOnInit() {
this.own( this.own(
this.schemasState.selectedSchema this.schemasState.selectedSchema.pipe(defined())
.subscribe(schema => { .subscribe(schema => {
this.schema = schema; this.schema = schema;
})); }));
@ -72,10 +63,6 @@ export class SchemaPageComponent extends ResourceOwner implements OnInit {
}); });
} }
public selectTab(tab: string) {
this.selectedTab = tab;
}
private back() { private back() {
this.router.navigate(['../'], { relativeTo: this.route }); 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"> <div class="inner-header">
<ul class="nav nav-tabs2"> <ul class="nav nav-tabs2">
<li class="nav-item" *ngFor="let script of editForm.form.controls | sqxKeys"> <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> </li>
</ul> </ul>

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

@ -1,8 +1,14 @@
<form class="inner-form" (ngSubmit)="saveSchema()"> <form class="inner-form" (ngSubmit)="saveSchema()">
<div class="inner-header"> <div class="inner-header">
<ul class="nav nav-tabs2"> <ul class="nav nav-tabs2">
<li class="nav-item" *ngFor="let tab of selectableTabs"> <li class="nav-item">
<a class="nav-link" [class.active]="selectedTab === tab" (click)="selectTab(tab)">{{tab | sqxTranslate}}</a> <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> </li>
</ul> </ul>
@ -12,7 +18,7 @@
</div> </div>
<div class="inner-main"> <div class="inner-main">
<sqx-field-list [class.hidden]="selectedTab !== 'i18n:schemas.listFields'" <sqx-field-list [class.hidden]="selectedTab !== 0"
[emptyText]="'schemas.listFieldsEmpty' | sqxTranslate" [emptyText]="'schemas.listFieldsEmpty' | sqxTranslate"
[schema]="schema" [schema]="schema"
[fieldNames]="state.fieldsInLists" [fieldNames]="state.fieldsInLists"
@ -20,7 +26,7 @@
[withMetaFields]="true"> [withMetaFields]="true">
</sqx-field-list> </sqx-field-list>
<sqx-field-list [class.hidden]="selectedTab !== 'i18n:schemas.referenceFields'" <sqx-field-list [class.hidden]="selectedTab !== 1"
[emptyText]="'schemas.referenceFieldsEmpty' | sqxTranslate" [emptyText]="'schemas.referenceFieldsEmpty' | sqxTranslate"
[schema]="schema" [schema]="schema"
[fieldNames]="state.fieldsInReferences" [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> }; type State = { fieldsInLists: ReadonlyArray<string>, fieldsInReferences: ReadonlyArray<string> };
const TABS: ReadonlyArray<string> = [
'i18n:schemas.listFields',
'i18n:schemas.referenceFields'
];
@Component({ @Component({
selector: 'sqx-schema-ui-form', selector: 'sqx-schema-ui-form',
styleUrls: ['./schema-ui-form.component.scss'], styleUrls: ['./schema-ui-form.component.scss'],
@ -24,8 +19,7 @@ export class SchemaUIFormComponent implements OnChanges {
@Input() @Input()
public schema: SchemaDetailsDto; public schema: SchemaDetailsDto;
public selectableTabs = TABS; public selectedTab = 0;
public selectedTab = this.selectableTabs[0];
public isEditable = false; public isEditable = false;
@ -56,7 +50,7 @@ export class SchemaUIFormComponent implements OnChanges {
this.state.fieldsInReferences = names; this.state.fieldsInReferences = names;
} }
public selectTab(tab: string) { public selectTab(tab: number) {
this.selectedTab = tab; 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() { public ngOnInit() {
this.rolesState.load(); 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() { public reload() {

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

@ -8,7 +8,7 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { FormBuilder } from '@angular/forms'; import { FormBuilder } from '@angular/forms';
import { Router } from '@angular/router'; 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({ @Component({
selector: 'sqx-more-page', selector: 'sqx-more-page',
@ -37,7 +37,7 @@ export class MorePageComponent extends ResourceOwner implements OnInit {
public ngOnInit() { public ngOnInit() {
this.own( this.own(
this.appsState.selectedApp this.appsState.selectedApp.pipe(defined())
.subscribe(app => { .subscribe(app => {
this.app = 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"> <div class="table-items-row-details-tabs clearfix">
<ul class="nav nav-tabs2"> <ul class="nav nav-tabs2">
<li class="nav-item"> <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>
<li class="nav-item"> <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> </li>
</ul> </ul>

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

@ -8,7 +8,7 @@
</ng-container> </ng-container>
<ng-container content> <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"> <li class="nav-item" *ngIf="app.canReadBackups">
<a class="nav-link" routerLink="backups" routerLinkActive="active"> <a class="nav-link" routerLink="backups" routerLinkActive="active">
{{ 'common.backups' | sqxTranslate }} {{ 'common.backups' | sqxTranslate }}

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

@ -6,7 +6,7 @@
*/ */
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { AppsState } from '@app/shared'; import { AppsState, defined } from '@app/shared';
@Component({ @Component({
selector: 'sqx-settings-area', selector: 'sqx-settings-area',
@ -14,8 +14,10 @@ import { AppsState } from '@app/shared';
templateUrl: './settings-area.component.html' templateUrl: './settings-area.component.html'
}) })
export class SettingsAreaComponent { export class SettingsAreaComponent {
public selectedApp = this.appsState.selectedApp.pipe(defined());
constructor( 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>(); public selectedLanguageChange = new EventEmitter<Language>();
@Input() @Input()
public size: 'sm' | 'md' | 'lg' = 'md'; public selectedLanguage: Language;
@Input() @Input()
public languages: ReadonlyArray<Language> = []; public languages: ReadonlyArray<Language> = [];
@Input() @Input()
public selectedLanguage: Language; public size: 'sm' | 'md' | 'lg' = 'md';
public dropdown = new ModalModel(); public dropdown = new ModalModel();

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

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

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

@ -5,21 +5,28 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
// tslint:disable: forin
// tslint:disable: readonly-array // tslint:disable: readonly-array
import { Injectable, OnDestroy } from '@angular/core'; 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 { LocalStoreService, Types } from '@app/framework/internal';
import { State } from '@app/framework/state'; import { State } from '@app/framework/state';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
export type QueryParams = { [name: string]: string };
export interface RouteSynchronizer { 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 { export class PagingSynchronizer implements RouteSynchronizer {
public readonly keys = ['page', 'pageSize'];
constructor( constructor(
private readonly localStore: LocalStoreService, private readonly localStore: LocalStoreService,
private readonly storeName: string, private readonly storeName: string,
@ -27,10 +34,10 @@ export class PagingSynchronizer implements RouteSynchronizer {
) { ) {
} }
public parseValuesFromRoute(params: Params) { public parseFromRoute(query: QueryParams) {
let pageSize = 0; let pageSize = 0;
const pageSizeValue = params['pageSize']; const pageSizeValue = query['pageSize'];
if (Types.isString(pageSizeValue)) { if (Types.isString(pageSizeValue)) {
pageSize = parseInt(pageSizeValue, 10); pageSize = parseInt(pageSizeValue, 10);
@ -44,7 +51,7 @@ export class PagingSynchronizer implements RouteSynchronizer {
pageSize = this.defaultSize; pageSize = this.defaultSize;
} }
let page = parseInt(params['page'], 10); let page = parseInt(query['page'], 10);
if (page <= 0 || isNaN(page)) { if (page <= 0 || isNaN(page)) {
page = 0; page = 0;
@ -53,56 +60,58 @@ export class PagingSynchronizer implements RouteSynchronizer {
return { page, pageSize }; return { page, pageSize };
} }
public writeValuesToRoute(state: any, params: Params) { public parseFromState(state: any) {
const page: number = state.page; let page = undefined;
if (page > 0) {
params['page'] = page.toString();
} else {
params['page'] = undefined;
}
const pageSize: number = state.pageSize; const pageSize: number = state.pageSize;
params['pageSize'] = pageSize.toString(); if (state.page > 0) {
page = state.page.toString();
}
this.localStore.setInt(`${this.storeName}.pageSize`, pageSize); this.localStore.setInt(`${this.storeName}.pageSize`, pageSize);
return { page, pageSize: pageSize.toString() };
} }
} }
export class StringSynchronizer implements RouteSynchronizer { export class StringSynchronizer implements RouteSynchronizer {
public get keys() {
return [this.key];
}
constructor( constructor(
private readonly nameState: string, private readonly key: string
private readonly nameUrl: string
) { ) {
} }
public parseValuesFromRoute(params: Params) { public parseFromRoute(params: QueryParams) {
const value = params[this.nameUrl]; const value = params[this.key];
return { [this.nameState]: value }; return { [this.key]: value };
} }
public writeValuesToRoute(state: any, params: Params) { public parseFromState(state: any) {
params[this.nameUrl] = undefined; const value = state[this.key];
const value = state[this.nameState];
if (Types.isString(value)) { if (Types.isString(value)) {
params[this.nameUrl] = value; return { [this.key]: value };
} }
} }
} }
export class StringKeysSynchronizer implements RouteSynchronizer { export class StringKeysSynchronizer implements RouteSynchronizer {
public get keys() {
return [this.key];
}
constructor( constructor(
private readonly nameState: string, private readonly key: string
private readonly nameUrl: string
) { ) {
} }
public parseValuesFromRoute(params: Params) { public parseFromRoute(query: QueryParams) {
const value = params[this.nameUrl]; const value = query[this.key];
const result: { [key: string]: boolean } = {}; 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) { public parseFromState(state: any) {
params[this.nameUrl] = undefined; const value = state[this.key];
const value = state[this.nameState];
if (Types.isObject(value)) { if (Types.isObject(value)) {
const items = Object.keys(value).join(','); const items = Object.keys(value).join(',');
if (items.length > 0) { if (items.length > 0) {
params[this.nameUrl] = items; return { [this.key]: items };
} }
} }
} }
@ -137,19 +144,15 @@ export interface StateSynchronizer {
} }
export interface StateSynchronizerMap<T> { export interface StateSynchronizerMap<T> {
keep(key: keyof T & string): this; withString(key: keyof T & string): this;
withString(key: keyof T & string, urlName: string): this; withStrings(key: keyof T & string): this;
withStrings(key: keyof T & string, urlName: string): this;
withPaging(storeName: string, defaultSize: number): this; withPaging(storeName: string, defaultSize: number): this;
whenSynced(action: () => void): this;
withSynchronizer(synchronizer: RouteSynchronizer): this; withSynchronizer(synchronizer: RouteSynchronizer): this;
build(): void; getInitial(): Partial<T>;
} }
@Injectable() @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() { public ngOnDestroy() {
this.mapper?.destroy(); this.unlisten();
} }
public mapTo<T extends object>(state: State<T>) { public mapTo<T extends object>(state: State<T>) {
this.mapper?.destroy(); this.mapper?.unlisten();
this.mapper = this.mapper || new Router2StateMap<T>(state, this.route, this.router, this.localStore); this.mapper = new Router2StateMap<T>(state, this.route, this.router, this.localStore);
return this.mapper; return this.mapper;
} }
} }
export class Router2StateMap<T extends object> implements StateSynchronizerMap<T> { export class Router2StateMap<T extends object> implements StateSynchronizerMap<T> {
private readonly syncs: { synchronizer: RouteSynchronizer, value: object }[] = []; private readonly syncs: RouteSynchronizer[] = [];
private readonly keysToKeep: string[] = []; private lastSyncedQuery: QueryParams;
private syncDone: (() => void)[] = []; private stateSubscription: Subscription;
private lastSyncedParams: Params | undefined;
private subscriptionChanges: Subscription;
private subscriptionQueryParams: Subscription;
private subscriptionEvents: Subscription;
private isNavigating = false;
private pendingParams?: Params;
constructor( constructor(
private readonly state: State<T>, private readonly state: State<T>,
@ -194,134 +203,62 @@ export class Router2StateMap<T extends object> implements StateSynchronizerMap<T
) { ) {
} }
public build() { public listen() {
this.subscriptionQueryParams = this.stateSubscription = this.state.changes.subscribe(s => this.syncToRoute(s));
this.route.queryParams
.subscribe(q => this.syncFromRoute(q));
this.subscriptionChanges = return this;
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 destroy() { public unlisten() {
this.syncDone = []; this.stateSubscription?.unsubscribe();
this.subscriptionQueryParams?.unsubscribe();
this.subscriptionChanges?.unsubscribe();
this.subscriptionEvents?.unsubscribe();
} }
private syncToRoute(state: T) { private syncToRoute(state: T) {
let isChanged = false; const query: Params = {};
for (const target of this.syncs) {
if (!target.value) {
isChanged = true;
}
for (const key in target.value) { for (const sync of this.syncs) {
if (target.value[key] !== state[key]) { const values = sync.parseFromState(state);
isChanged = true;
break;
}
}
if (isChanged) { for (const key of sync.keys) {
break; query[key] = values?.[key];
} }
} }
if (!isChanged) { if (Types.equals(this.lastSyncedQuery, query)) {
return; return;
} }
const query: Params = {}; this.lastSyncedQuery = query;
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.router.navigate([], { this.router.navigate([], {
queryParams: query, queryParams: query,
queryParamsHandling: 'merge', queryParamsHandling: 'merge',
replaceUrl: true 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> = {}; const update: Partial<T> = {};
for (const target of this.syncs) { const query = this.route.snapshot.queryParams;
const values = target.synchronizer.parseValuesFromRoute(query);
for (const key in values) {
if (values.hasOwnProperty(key)) {
update[key] = values[key];
}
}
target.value = values;
}
for (const key of this.keysToKeep) { for (const sync of this.syncs) {
update[key] = this.state.snapshot[key]; const values = sync.parseFromRoute(query);
}
if (this.state.resetState(update)) { for (const key of sync.keys) {
for (const action of this.syncDone) { update[key] = values?.[key];
action();
} }
} }
}
public keep(key: keyof T & string) {
this.keysToKeep.push(key);
return this; return update;
} }
public withString(key: keyof T & string, urlName: string) { public withString(key: keyof T & string) {
return this.withSynchronizer(new StringSynchronizer(key, urlName)); return this.withSynchronizer(new StringSynchronizer(key));
} }
public withStrings(key: keyof T & string, urlName: string) { public withStrings(key: keyof T & string) {
return this.withSynchronizer(new StringKeysSynchronizer(key, urlName)); return this.withSynchronizer(new StringKeysSynchronizer(key));
} }
public withPaging(storeName: string, defaultSize = 10) { public withPaging(storeName: string, defaultSize = 10) {
@ -329,28 +266,8 @@ export class Router2StateMap<T extends object> implements StateSynchronizerMap<T
} }
public withSynchronizer(synchronizer: RouteSynchronizer) { public withSynchronizer(synchronizer: RouteSynchronizer) {
this.syncs.push({ synchronizer, value: {} }); this.syncs.push(synchronizer);
return this;
}
public whenSynced(action: () => void) {
this.syncDone.push(action);
return this; 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"> <sqx-modal-dialog (close)="emitComplete()" size="xl" fullHeight="true" [title]="false" [showFooter]="false">
<ng-container plainTitle> <ng-container plainTitle>
<ul class="nav nav-tabs2"> <ul class="nav nav-tabs2">
<li class="nav-item" *ngFor="let tab of selectableTabs"> <li class="nav-item">
<a class="nav-link" [class.active]="tab === selectedTab" (click)="selectTab(tab)">{{tab | sqxTranslate}}</a> <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> </li>
</ul> </ul>
<ng-container [ngSwitch]="selectedTab"> <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"> <button type="submit" class="btn btn-primary ml-auto mr-4" [class.invisible]="!isEditable">
{{ 'common.save' | sqxTranslate }} {{ 'common.save' | sqxTranslate }}
</button> </button>
</ng-container> </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"> <button type="button" class="btn btn-primary ml-auto mr-4" (click)="cropImage()" [class.invisible]="!isUploadable" [disabled]="progress > 0">
{{ 'common.save' | sqxTranslate }} {{ 'common.save' | sqxTranslate }}
</button> </button>
</ng-container> </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"> <button type="button" class="btn btn-primary ml-auto mr-4" (click)="setFocusPoint()" [class.invisible]="!isEditable">
{{ 'common.save' | sqxTranslate }} {{ 'common.save' | sqxTranslate }}
</button> </button>
@ -28,35 +45,7 @@
<ng-container content> <ng-container content>
<ng-container [ngSwitch]="selectedTab"> <ng-container [ngSwitch]="selectedTab">
<ng-container *ngSwitchCase="'i18n:assets.tabImage'"> <ng-container *ngSwitchCase="0">
<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'">
<div class="metadata"> <div class="metadata">
<sqx-form-error [error]="annotateForm.error | async"></sqx-form-error> <sqx-form-error [error]="annotateForm.error | async"></sqx-form-error>
@ -167,7 +156,35 @@
</div> </div>
</div> </div>
</ng-container> </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> <sqx-asset-history [asset]="asset"></sqx-asset-history>
</ng-container> </ng-container>
</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 { ImageCropperComponent } from './image-cropper.component';
import { ImageFocusPointComponent } from './image-focus-point.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({ @Component({
selector: 'sqx-asset-dialog', selector: 'sqx-asset-dialog',
styleUrls: ['./asset-dialog.component.scss'], styleUrls: ['./asset-dialog.component.scss'],
@ -59,11 +47,14 @@ export class AssetDialogComponent implements OnChanges {
public progress = 0; public progress = 0;
public selectableTabs: ReadonlyArray<string>; public selectedTab = 0;
public selectedTab: string;
public annotateForm = new AnnotateAssetForm(this.formBuilder); public annotateForm = new AnnotateAssetForm(this.formBuilder);
public get isImage() {
return this.asset.type === 'Image';
}
constructor( constructor(
private readonly appsState: AppsState, private readonly appsState: AppsState,
private readonly assetsState: AssetsState, private readonly assetsState: AssetsState,
@ -77,22 +68,14 @@ export class AssetDialogComponent implements OnChanges {
} }
public ngOnChanges() { public ngOnChanges() {
this.selectTab(0);
this.isEditable = this.asset.canUpdate; this.isEditable = this.asset.canUpdate;
this.isUploadable = this.asset.canUpload; this.isUploadable = this.asset.canUpload;
this.annotateForm.load(this.asset); this.annotateForm.load(this.asset);
this.annotateForm.setEnabled(this.isEditable); 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.path =
this.assetsService.getAssetFolders(this.appsState.appName, this.asset.parentId).pipe( this.assetsService.getAssetFolders(this.appsState.appName, this.asset.parentId).pipe(
map(folders => [ROOT_ITEM, ...folders.path])); map(folders => [ROOT_ITEM, ...folders.path]));
@ -102,7 +85,7 @@ export class AssetDialogComponent implements OnChanges {
this.assetsState.navigate(id); this.assetsState.navigate(id);
} }
public selectTab(tab: string) { public selectTab(tab: number) {
this.selectedTab = tab; 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"> <ng-container *ngIf="app.canUploadAssets">
<ul class="nav navbar-nav align-items-center" *ngIf="assetUploader.uploads | async; let uploads" (sqxDropFile)="addFiles($event)"> <ul class="nav navbar-nav align-items-center" *ngIf="assetUploader.uploads | async; let uploads" (sqxDropFile)="addFiles($event)">
<li class="nav-item nav-icon dropdown"> <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'"> <ng-container *ngSwitchCase="'number'">
<input type="number" class="form-control" <input type="number" class="form-control"
[ngModel]="filter.value" [ngModel]="filter.value"
(ngModelChange)="changeValue($event)" /> (ngModelChange)="changeValue($event)"
/>
</ng-container> </ng-container>
<ng-container *ngSwitchCase="'reference'"> <ng-container *ngSwitchCase="'reference'">
<sqx-references-dropdown [schemaId]="fieldModel.extra" <sqx-references-dropdown [schemaId]="fieldModel.extra"

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

@ -44,7 +44,10 @@
<ng-container content> <ng-container content>
<div class="form-horizontal"> <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> </sqx-query>
<div class="link" [innerHTML]="'search.help' | sqxTranslate | sqxMarkdown"></div> <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 { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from '@angular/router'; import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from '@angular/router';
import { defined } from '@app/framework';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { map, take, tap } from 'rxjs/operators'; import { map, take, tap } from 'rxjs/operators';
import { SchemasState } from './../state/schemas.state'; import { SchemasState } from './../state/schemas.state';
@ -22,6 +23,7 @@ export class SchemaMustNotBeSingletonGuard implements CanActivate {
public canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> { public canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> {
const result = const result =
this.schemasState.selectedSchema.pipe( this.schemasState.selectedSchema.pipe(
defined(),
take(1), take(1),
tap(schema => { tap(schema => {
if (schema.isSingleton) { if (schema.isSingleton) {

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

@ -5,8 +5,7 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { StateSynchronizer, StateSynchronizerMap } from '@app/framework'; import { of } from 'rxjs';
import { of, Subject } from 'rxjs';
import { Mock } from 'typemoq'; import { Mock } from 'typemoq';
import { AppsState, AuthService, DateTime, Version } from './../'; import { AppsState, AuthService, DateTime, Version } from './../';
@ -23,9 +22,6 @@ const appsState = Mock.ofType<AppsState>();
appsState.setup(x => x.appName) appsState.setup(x => x.appName)
.returns(() => app); .returns(() => app);
appsState.setup(x => x.selectedAppOrNull)
.returns(() => of(<any>{ name: app }));
appsState.setup(x => x.selectedApp) appsState.setup(x => x.selectedApp)
.returns(() => of(<any>{ name: app })); .returns(() => of(<any>{ name: app }));
@ -34,64 +30,10 @@ const authService = Mock.ofType<AuthService>();
authService.setup(x => x.user) authService.setup(x => x.user)
.returns(() => <any>{ id: modifier, token: modifier }); .returns(() => <any>{ id: modifier, token: modifier });
class DummySynchronizer implements StateSynchronizer, StateSynchronizerMap<any> {
constructor(
private readonly subject: Subject<any>
) {
}
public build() {
return;
}
public mapTo<T extends object>(): StateSynchronizerMap<T> {
return this;
}
public keep() {
return this;
}
public withString() {
return this;
}
public withStrings() {
return this;
}
public 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 = { export const TestValues = {
app, app,
appsState, appsState,
authService, authService,
buildDummyStateSynchronizer,
creation, creation,
creator, creator,
modified, modified,

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

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

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

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

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

@ -6,12 +6,12 @@
*/ */
import { Injectable } from '@angular/core'; 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 { EMPTY, forkJoin, Observable, of, throwError } from 'rxjs';
import { catchError, finalize, switchMap, tap } from 'rxjs/operators'; import { catchError, finalize, switchMap, tap } from 'rxjs/operators';
import { AnnotateAssetDto, AssetDto, AssetFolderDto, AssetFoldersDto, AssetsService, RenameAssetFolderDto } from './../services/assets.service'; import { AnnotateAssetDto, AssetDto, AssetFolderDto, AssetFoldersDto, AssetsService, RenameAssetFolderDto } from './../services/assets.service';
import { AppsState } from './apps.state'; import { AppsState } from './apps.state';
import { Query, QueryFullTextSynchronizer } from './query'; import { Query } from './query';
export type AssetPathItem = { id: string, folderName: string }; export type AssetPathItem = { id: string, folderName: string };
@ -123,19 +123,9 @@ export class AssetsState extends State<Snapshot> {
}); });
} }
public loadAndListen(synchronizer: StateSynchronizer) { public load(isReload = false, update: Partial<Snapshot> = {}): Observable<any> {
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> {
if (!isReload) { if (!isReload) {
this.resetState(); this.resetState(update);
} }
return this.loadInternal(isReload); return this.loadInternal(isReload);

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

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

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

@ -17,7 +17,6 @@ describe('ContributorsState', () => {
const { const {
app, app,
appsState, appsState,
buildDummyStateSynchronizer,
newVersion, newVersion,
version version
} = TestValues; } = TestValues;
@ -122,19 +121,6 @@ describe('ContributorsState', () => {
expect(contributorsState.snapshot.pageSize).toEqual(10); 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', () => { it('should show notification on load when reload is true', () => {
contributorsState.load(true).subscribe(); contributorsState.load(true).subscribe();

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

@ -6,7 +6,7 @@
*/ */
import { Injectable } from '@angular/core'; 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 { EMPTY, Observable, throwError } from 'rxjs';
import { catchError, finalize, tap } from 'rxjs/operators'; import { catchError, finalize, tap } from 'rxjs/operators';
import { AssignContributorDto, ContributorDto, ContributorsPayload, ContributorsService } from './../services/contributors.service'; import { AssignContributorDto, ContributorDto, ContributorsPayload, ContributorsService } from './../services/contributors.service';
@ -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> { public loadIfNotLoaded(): Observable<any> {
if (this.snapshot.isLoaded) { if (this.snapshot.isLoaded) {
return EMPTY; return EMPTY;
@ -90,9 +82,9 @@ export class ContributorsState extends State<Snapshot> {
return this.loadInternal(false); return this.loadInternal(false);
} }
public load(isReload = false): Observable<any> { public load(isReload = false, update: Partial<Snapshot> = {}): Observable<any> {
if (!isReload) { if (!isReload) {
this.resetState({ page: 0 }); this.resetState(update);
} }
return this.loadInternal(isReload); return this.loadInternal(isReload);

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

@ -58,9 +58,12 @@ export class LanguagesState extends State<Snapshot> {
public languages = public languages =
this.project(x => x.languages); this.project(x => x.languages);
public languagesDtos = public isoLanguages =
this.project(x => x.languages.map(y => y.language)); this.project(x => x.languages.map(y => y.language));
public isoMasterLanguage =
this.projectFrom(this.isoLanguages, x => x.find(l => l.isMaster)!);
public newLanguages = public newLanguages =
this.project(x => x.allLanguagesNew); this.project(x => x.allLanguagesNew);
@ -98,7 +101,9 @@ export class LanguagesState extends State<Snapshot> {
private loadInternal(isReload: boolean): Observable<any> { private loadInternal(isReload: boolean): Observable<any> {
this.next({ isLoading: true }); this.next({ isLoading: true });
return forkJoin(this.getAllLanguages(), this.getAppLanguages()).pipe( return forkJoin([
this.getAllLanguages(),
this.getAppLanguages()]).pipe(
map(args => { map(args => {
return { allLanguages: args[0], languages: args[1] }; 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. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { Params } from '@angular/router'; import { Query, QueryParams } from '@app/shared/internal';
import { Query } from '@app/shared/internal';
import { equalsQuery, QueryFullTextSynchronizer, QuerySynchronizer } from './query'; import { equalsQuery, QueryFullTextSynchronizer, QuerySynchronizer } from './query';
describe('equalsQuery', () => { describe('equalsQuery', () => {
@ -59,103 +58,94 @@ describe('equalsQuery', () => {
describe('QueryFullTextSynchronizer', () => { describe('QueryFullTextSynchronizer', () => {
const synchronizer = new QueryFullTextSynchronizer(); const synchronizer = new QueryFullTextSynchronizer();
it('should write full text to route', () => { it('should parse from state', () => {
const params: Params = {};
const value = { fullText: 'my-query' }; 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', () => { it('should parse from state as undefined when not a query', () => {
const params: Params = {};
const value = 123; 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', () => { it('should parse from state as undefined when no full text', () => {
const params: Params = {}; 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: '' }; 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', () => { it('should get query from route', () => {
const params: Params = { const params: QueryParams = { query: 'my-query' };
query: 'my-query'
};
const value = synchronizer.parseValuesFromRoute(params); const value = synchronizer.parseFromRoute(params);
expect(value).toEqual({ query: { fullText: 'my-query' } }); expect(value).toEqual({ query: { fullText: 'my-query' } });
}); });
it('should get query as undefined from route', () => { 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', () => { describe('QuerySynchronizer', () => {
const synchronizer = new QuerySynchronizer(); const synchronizer = new QuerySynchronizer();
it('should write query to route', () => { it('should parse from state', () => {
const params: Params = {};
const value = { filter: 'my-filter' }; 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 parse from state as undefined when not a query', () => {
it('Should write undefined when not a query', () => {
const params: Params = {};
const value = 123; 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', () => { it('should get query from route', () => {
const params: Params = { const params: QueryParams = { query: '{"filter":"my-filter"}' };
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' } }); expect(value).toEqual({ query: { filter: 'my-filter' } });
}); });
it('should get query full text from route', () => { it('should get query full text from route', () => {
const params: Params = { const params: QueryParams = { query: 'my-query' };
query: 'my-query'
};
const value = synchronizer.parseValuesFromRoute(params); const value = synchronizer.parseFromRoute(params);
expect(value).toEqual({ query: { fullText: 'my-query' } }); expect(value).toEqual({ query: { fullText: 'my-query' } });
}); });
it('should get query as undefined from route', () => { 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 // tslint:disable: readonly-array
import { Params } from '@angular/router'; import { QueryParams, RouteSynchronizer, Types } from '@app/framework';
import { RouteSynchronizer, Types } from '@app/framework';
import { StatusInfo } from './../services/contents.service'; import { StatusInfo } from './../services/contents.service';
import { LanguageDto } from './../services/languages.service'; import { LanguageDto } from './../services/languages.service';
import { MetaFields, SchemaDetailsDto } from './../services/schemas.service'; import { MetaFields, SchemaDetailsDto } from './../services/schemas.service';
@ -122,21 +121,21 @@ const DEFAULT_QUERY = {
export class QueryFullTextSynchronizer implements RouteSynchronizer { export class QueryFullTextSynchronizer implements RouteSynchronizer {
public static readonly INSTANCE = new QueryFullTextSynchronizer(); public static readonly INSTANCE = new QueryFullTextSynchronizer();
public parseValuesFromRoute(params: Params) { public readonly keys = ['query'];
public parseFromRoute(params: QueryParams) {
const query = params['query']; const query = params['query'];
if (Types.isString(query)) { if (Types.isString(query)) {
return { query: { fullText: query } }; return { query: { fullText: query } };
} }
return { query: undefined };
} }
public writeValuesToRoute(state: any, params: Params) { public parseFromState(state: any) {
params['query'] = undefined; const value = state['query'];
if (Types.isObject(state) && Types.isString(state.fullText) && state.fullText.length > 0) { if (Types.isObject(value) && Types.isString(value.fullText) && value.fullText.length > 0) {
params['query'] = state.fullText; return { query: value.fullText };
} }
} }
} }
@ -144,21 +143,21 @@ export class QueryFullTextSynchronizer implements RouteSynchronizer {
export class QuerySynchronizer implements RouteSynchronizer { export class QuerySynchronizer implements RouteSynchronizer {
public static readonly INSTANCE = new QuerySynchronizer(); public static readonly INSTANCE = new QuerySynchronizer();
public parseValuesFromRoute(params: Params) { public readonly keys = ['query'];
public parseFromRoute(params: QueryParams) {
const query = params['query']; const query = params['query'];
if (Types.isString(query)) { if (Types.isString(query)) {
return { query: deserializeQuery(query) }; return { query: deserializeQuery(query) };
} }
return { query: undefined };
} }
public writeValuesToRoute(state: any, params: Params) { public parseFromState(state: any) {
params['query'] = undefined; const value = state['query'];
if (Types.isObject(state)) { if (Types.isObject(value)) {
params['query'] = serializeQuery(state); return { query: serializeQuery(value) };
} }
} }
} }

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

@ -31,7 +31,7 @@ describe('RuleEventsState', () => {
dialogs = Mock.ofType<DialogService>(); dialogs = Mock.ofType<DialogService>();
rulesService = Mock.ofType<RulesService>(); 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))); .returns(() => of(new RuleEventsDto(200, oldRuleEvents)));
ruleEventsState = new RuleEventsState(appsState.object, dialogs.object, rulesService.object); ruleEventsState = new RuleEventsState(appsState.object, dialogs.object, rulesService.object);
@ -48,7 +48,7 @@ describe('RuleEventsState', () => {
}); });
it('should reset loading when loading failed', () => { 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')); .returns(() => throwError('error'));
ruleEventsState.load().pipe(onErrorResumeNext()).subscribe(); ruleEventsState.load().pipe(onErrorResumeNext()).subscribe();
@ -65,30 +65,30 @@ describe('RuleEventsState', () => {
}); });
it('should load with new pagination when paging', () => { 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, []))); .returns(() => of(new RuleEventsDto(200, [])));
ruleEventsState.page({ page: 1, pageSize: 10 }).subscribe(); ruleEventsState.page({ page: 1, pageSize: 30 }).subscribe();
expect().nothing(); expect().nothing();
rulesService.verify(x => x.getEvents(app, 10, 10, undefined), Times.once()); rulesService.verify(x => x.getEvents(app, 30, 30, undefined), Times.once());
rulesService.verify(x => x.getEvents(app, 10, 0, undefined), Times.once()); rulesService.verify(x => x.getEvents(app, 30, 0, undefined), Times.once());
}); });
it('should load with rule id when filtered', () => { it('should load with rule id when filtered', () => {
rulesService.setup(x => x.getEvents(app, 10, 0, '12')) rulesService.setup(x => x.getEvents(app, 30, 0, '12'))
.returns(() => of(new RuleEventsDto(200, []))); .returns(() => of(new RuleEventsDto(200, [])));
ruleEventsState.filterByRule('12').subscribe(); ruleEventsState.filterByRule('12').subscribe();
expect().nothing(); 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', () => { 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, []))); .returns(() => of(new RuleEventsDto(200, [])));
ruleEventsState.filterByRule('12').subscribe(); ruleEventsState.filterByRule('12').subscribe();
@ -96,7 +96,7 @@ describe('RuleEventsState', () => {
expect().nothing(); 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', () => { 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 { 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 { EMPTY, Observable } from 'rxjs';
import { finalize, tap } from 'rxjs/operators'; import { finalize, tap } from 'rxjs/operators';
import { RuleEventDto, RulesService } from './../services/rules.service'; import { RuleEventDto, RulesService } from './../services/rules.service';
@ -45,22 +45,14 @@ export class RuleEventsState extends State<Snapshot> {
super({ super({
ruleEvents: [], ruleEvents: [],
page: 0, page: 0,
pageSize: 10, pageSize: 30,
total: 0 total: 0
}); });
} }
public loadAndListen(route: Router2State) { public load(isReload = false, update: Partial<Snapshot> = {}): Observable<any> {
route.mapTo(this)
.withPaging('ruleEvents', 30)
.withString('ruleId', 'ruleId')
.whenSynced(() => this.loadInternal(false))
.build();
}
public load(isReload = false): Observable<any> {
if (!isReload) { if (!isReload) {
this.resetState(); this.resetState({ ruleId: this.snapshot.ruleId, ...update });
} }
return this.loadInternal(isReload); return this.loadInternal(isReload);

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

@ -6,7 +6,7 @@
*/ */
import { Injectable } from '@angular/core'; 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 { EMPTY, Observable, of } from 'rxjs';
import { catchError, finalize, tap } from 'rxjs/operators'; import { catchError, finalize, tap } from 'rxjs/operators';
import { AddFieldDto, CreateSchemaDto, FieldDto, FieldRule, NestedFieldDto, RootFieldDto, SchemaDetailsDto, SchemaDto, SchemasService, UpdateFieldDto, UpdateSchemaDto, UpdateUIFields } from './../services/schemas.service'; 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 SchemasList = ReadonlyArray<SchemaDto>;
export type SchemaCategory = { name: string; schemas: SchemasList; upper: string; }; 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() @Injectable()
export class SchemasState extends State<Snapshot> { export class SchemasState extends State<Snapshot> {
public categoryNames = public categoryNames =
this.project(x => x.categories); this.project(x => x.categories);
public selectedSchemaOrNull =
this.project(x => x.selectedSchema, sameSchema);
public selectedSchema = public selectedSchema =
this.selectedSchemaOrNull.pipe(defined()); this.project(x => x.selectedSchema);
public schemas = public schemas =
this.project(x => x.schemas); this.project(x => x.schemas);

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

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

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

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

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

@ -6,7 +6,7 @@
*/ */
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { AppsState } from '@app/shared'; import { AppsState, defined } from '@app/shared';
@Component({ @Component({
selector: 'sqx-app-area', selector: 'sqx-app-area',
@ -14,8 +14,10 @@ import { AppsState } from '@app/shared';
templateUrl: './app-area.component.html' templateUrl: './app-area.component.html'
}) })
export class AppAreaComponent { export class AppAreaComponent {
public selectedApp = this.appsState.selectedApp.pipe(defined());
constructor( 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"> <li class="nav-item" *ngIf="!isSchemasHidden(app) && app.canReadSchemas">
<a class="nav-link" routerLink="schemas" routerLinkActive="active"> <a class="nav-link" routerLink="schemas" routerLinkActive="active">
<i class="nav-icon icon-schemas"></i> <div class="nav-text">{{ 'common.schemas' | sqxTranslate }}</div> <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. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { ChangeDetectionStrategy, Component } from '@angular/core'; import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { AppDto, AppsState, Settings } from '@app/shared'; import { AppDto, Settings } from '@app/shared';
@Component({ @Component({
selector: 'sqx-left-menu', selector: 'sqx-left-menu',
@ -15,10 +15,8 @@ import { AppDto, AppsState, Settings } from '@app/shared';
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class LeftMenuComponent { export class LeftMenuComponent {
constructor( @Input()
public readonly appsState: AppsState public app: AppDto;
) {
}
public isAssetsHidden(app: AppDto) { public isAssetsHidden(app: AppDto) {
return app.roleProperties[Settings.AppProperties.HIDE_ASSETS] === true; 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"> <ul class="nav navbar-nav align-items-center">
<li class="nav-item dropdown"> <li class="nav-item dropdown">
<span class="nav-link dropdown-toggle" id="app-name" (click)="appsMenu.toggle()" #button> <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}} {{app.displayName}}
</ng-container> </ng-container>
@ -40,7 +40,7 @@
</ng-container> </ng-container>
</li> </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"> <li class="nav-item" *ngIf="app.planUpgrade && app.planName">
<div class="btn-group app-upgrade"> <div class="btn-group app-upgrade">
<button type="button" class="btn btn-primary btn-plan"> <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. // True when the submenu should be open.
showSubmenu: boolean; showSubmenu: boolean;
} }
@Component({ @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"> <div class="search-container ml-4">
<sqx-autocomplete [source]="searchSource" inputName="searchMenu" placeholder="{{ 'search.quickNavPlaceholder' | sqxTranslate }}" icon="search" #searchControl dropdownWidth="30rem" <sqx-autocomplete [source]="searchSource" inputName="searchMenu" placeholder="{{ 'search.quickNavPlaceholder' | sqxTranslate }}" icon="search" #searchControl dropdownWidth="30rem"
(ngModelChange)="selectResult($event)"> (ngModelChange)="selectResult($event)">

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

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

Loading…
Cancel
Save