Browse Source

Simplify clients. (#917)

* Simplify clients.

* Fix tests.
pull/918/head
Sebastian Stehle 3 years ago
committed by GitHub
parent
commit
48e5fe5f3c
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 9
      backend/src/Squidex/Areas/Api/Controllers/UI/MyUIOptions.cs
  2. 7
      backend/src/Squidex/appsettings.json
  3. 1
      frontend/.eslintrc.js
  4. 4
      frontend/src/app/features/administration/pages/users/user-page.component.ts
  5. 7
      frontend/src/app/features/administration/services/event-consumers.service.spec.ts
  6. 27
      frontend/src/app/features/administration/services/event-consumers.service.ts
  7. 18
      frontend/src/app/features/administration/services/users.service.spec.ts
  8. 54
      frontend/src/app/features/administration/services/users.service.ts
  9. 8
      frontend/src/app/features/administration/state/event-consumers.state.spec.ts
  10. 4
      frontend/src/app/features/administration/state/users.forms.ts
  11. 26
      frontend/src/app/features/administration/state/users.state.spec.ts
  12. 6
      frontend/src/app/features/administration/state/users.state.ts
  13. 2
      frontend/src/app/features/api/pages/graphql/graphql-page.component.ts
  14. 2
      frontend/src/app/features/content/pages/content/content-page.component.ts
  15. 2
      frontend/src/app/features/content/pages/content/editor/content-field.component.ts
  16. 4
      frontend/src/app/features/content/pages/contents/contents-page.component.ts
  17. 2
      frontend/src/app/features/content/shared/forms/array-editor.component.ts
  18. 4
      frontend/src/app/features/content/shared/forms/array-item.component.ts
  19. 2
      frontend/src/app/features/content/shared/forms/component.component.ts
  20. 2
      frontend/src/app/features/content/shared/forms/field-editor.component.html
  21. 2
      frontend/src/app/features/content/shared/references/reference-dropdown.component.ts
  22. 6
      frontend/src/app/features/content/shared/references/reference-item.component.ts
  23. 2
      frontend/src/app/features/content/shared/references/references-tags.component.ts
  24. 2
      frontend/src/app/features/rules/pages/messages.ts
  25. 2
      frontend/src/app/features/rules/pages/rule/rule-page.component.ts
  26. 4
      frontend/src/app/features/schemas/pages/schema/ui/field-list.component.ts
  27. 2
      frontend/src/app/features/settings/pages/backups/backup.component.ts
  28. 8
      frontend/src/app/features/settings/pages/templates/template.component.ts
  29. 4
      frontend/src/app/features/settings/pages/workflows/workflow.component.ts
  30. 2
      frontend/src/app/framework/angular/compensate-scrollbar.directive.ts
  31. 2
      frontend/src/app/framework/angular/forms/editors/code-editor.component.ts
  32. 2
      frontend/src/app/framework/angular/forms/editors/tag-editor.stories.ts
  33. 2
      frontend/src/app/framework/angular/if-once.directive.ts
  34. 1
      frontend/src/app/framework/internal.ts
  35. 3
      frontend/src/app/framework/module.ts
  36. 68
      frontend/src/app/framework/services/analytics.service.ts
  37. 2
      frontend/src/app/framework/services/dialog.service.ts
  38. 19
      frontend/src/app/framework/state.ts
  39. 2
      frontend/src/app/framework/utils/modal-positioner.ts
  40. 4
      frontend/src/app/framework/utils/text-measurer.ts
  41. 2
      frontend/src/app/shared/components/app-form.component.ts
  42. 46
      frontend/src/app/shared/components/contents/content-list-cell.directive.ts
  43. 4
      frontend/src/app/shared/components/contents/content-list-field.component.ts
  44. 10
      frontend/src/app/shared/components/contents/content-list-header.component.ts
  45. 2
      frontend/src/app/shared/components/forms/markdown-editor.component.ts
  46. 2
      frontend/src/app/shared/components/forms/rich-editor.component.ts
  47. 2
      frontend/src/app/shared/components/pipes.ts
  48. 4
      frontend/src/app/shared/components/references/content-selector-item.component.ts
  49. 6
      frontend/src/app/shared/components/references/content-selector.component.ts
  50. 2
      frontend/src/app/shared/components/references/reference-input.component.ts
  51. 6
      frontend/src/app/shared/services/app-languages.service.spec.ts
  52. 74
      frontend/src/app/shared/services/app-languages.service.ts
  53. 15
      frontend/src/app/shared/services/apps.service.spec.ts
  54. 80
      frontend/src/app/shared/services/apps.service.ts
  55. 24
      frontend/src/app/shared/services/assets.service.spec.ts
  56. 192
      frontend/src/app/shared/services/assets.service.ts
  57. 11
      frontend/src/app/shared/services/backups.service.spec.ts
  58. 49
      frontend/src/app/shared/services/backups.service.ts
  59. 6
      frontend/src/app/shared/services/clients.service.spec.ts
  60. 84
      frontend/src/app/shared/services/clients.service.ts
  61. 9
      frontend/src/app/shared/services/comments.service.ts
  62. 16
      frontend/src/app/shared/services/contents.service.spec.ts
  63. 171
      frontend/src/app/shared/services/contents.service.ts
  64. 12
      frontend/src/app/shared/services/contributors.service.spec.ts
  65. 69
      frontend/src/app/shared/services/contributors.service.ts
  66. 21
      frontend/src/app/shared/services/history.service.ts
  67. 10
      frontend/src/app/shared/services/languages.service.ts
  68. 19
      frontend/src/app/shared/services/news.service.spec.ts
  69. 44
      frontend/src/app/shared/services/news.service.ts
  70. 3
      frontend/src/app/shared/services/plans.service.spec.ts
  71. 75
      frontend/src/app/shared/services/plans.service.ts
  72. 2
      frontend/src/app/shared/services/query.ts
  73. 6
      frontend/src/app/shared/services/roles.service.spec.ts
  74. 71
      frontend/src/app/shared/services/roles.service.ts
  75. 45
      frontend/src/app/shared/services/rules.service.spec.ts
  76. 175
      frontend/src/app/shared/services/rules.service.ts
  77. 8
      frontend/src/app/shared/services/schemas.service.spec.ts
  78. 265
      frontend/src/app/shared/services/schemas.service.ts
  79. 18
      frontend/src/app/shared/services/schemas.spec.ts
  80. 18
      frontend/src/app/shared/services/search.service.ts
  81. 17
      frontend/src/app/shared/services/stock-photo.service.ts
  82. 7
      frontend/src/app/shared/services/templates.service.spec.ts
  83. 32
      frontend/src/app/shared/services/templates.service.ts
  84. 12
      frontend/src/app/shared/services/translations.service.ts
  85. 6
      frontend/src/app/shared/services/ui.service.ts
  86. 12
      frontend/src/app/shared/services/users.service.spec.ts
  87. 17
      frontend/src/app/shared/services/users.service.ts
  88. 10
      frontend/src/app/shared/services/workflows.service.spec.ts
  89. 124
      frontend/src/app/shared/services/workflows.service.ts
  90. 2
      frontend/src/app/shared/state/asset-uploader.state.ts
  91. 40
      frontend/src/app/shared/state/assets.state.spec.ts
  92. 8
      frontend/src/app/shared/state/backups.state.spec.ts
  93. 11
      frontend/src/app/shared/state/comments.state.ts
  94. 2
      frontend/src/app/shared/state/contents.forms-helpers.ts
  95. 16
      frontend/src/app/shared/state/contents.forms.spec.ts
  96. 2
      frontend/src/app/shared/state/contents.state.ts
  97. 2
      frontend/src/app/shared/state/contributors.state.ts
  98. 20
      frontend/src/app/shared/state/resolvers.spec.ts
  99. 8
      frontend/src/app/shared/state/resolvers.ts
  100. 10
      frontend/src/app/shared/state/rule-events.state.spec.ts

9
backend/src/Squidex/Areas/Api/Controllers/UI/MyUIOptions.cs

@ -20,9 +20,6 @@ namespace Squidex.Areas.Api.Controllers.UI
[JsonPropertyName("map")] [JsonPropertyName("map")]
public MapOptions Map { get; set; } public MapOptions Map { get; set; }
[JsonPropertyName("google")]
public GoogleOptions Google { get; set; }
[JsonPropertyName("referencesDropdownItemCount")] [JsonPropertyName("referencesDropdownItemCount")]
public int ReferencesDropdownItemCount { get; set; } = 100; public int ReferencesDropdownItemCount { get; set; } = 100;
@ -64,11 +61,5 @@ namespace Squidex.Areas.Api.Controllers.UI
[JsonPropertyName("key")] [JsonPropertyName("key")]
public string Key { get; set; } public string Key { get; set; }
} }
public sealed class GoogleOptions
{
[JsonPropertyName("analyticsId")]
public string AnalyticsId { get; set; }
}
} }
} }

7
backend/src/Squidex/appsettings.json

@ -152,12 +152,7 @@
"showInfo": false, "showInfo": false,
// The number of content items for dropdown selector. // The number of content items for dropdown selector.
"referencesDropdownItemCount": 100, "referencesDropdownItemCount": 100
"google": {
// The Google analytics ID.
"analyticsId": "UA-99989790-2"
}
}, },
"email": { "email": {

1
frontend/.eslintrc.js

@ -107,6 +107,7 @@ module.exports = {
"no-plusplus": "off", "no-plusplus": "off",
"no-prototype-builtins": "off", "no-prototype-builtins": "off",
"no-restricted-syntax": "off", "no-restricted-syntax": "off",
"no-trailing-spaces": "error",
"no-underscore-dangle": "off", "no-underscore-dangle": "off",
"object-curly-newline": [ "object-curly-newline": [
"error", "error",

4
frontend/src/app/features/administration/pages/users/user-page.component.ts

@ -7,7 +7,7 @@
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 { CreateUserDto, UserDto, UserForm, UsersState } from '@app/features/administration/internal'; import { UpsertUserDto, UserDto, UserForm, UsersState } from '@app/features/administration/internal';
import { ResourceOwner } from '@app/shared'; import { ResourceOwner } from '@app/shared';
@Component({ @Component({
@ -63,7 +63,7 @@ export class UserPageComponent extends ResourceOwner implements OnInit {
}, },
}); });
} else { } else {
this.usersState.create(<CreateUserDto>value) this.usersState.create(<UpsertUserDto>value)
.subscribe({ .subscribe({
next: () => { next: () => {
this.back(); this.back();

7
frontend/src/app/features/administration/services/event-consumers.service.spec.ts

@ -47,11 +47,12 @@ describe('EventConsumersService', () => {
], ],
}); });
expect(eventConsumers!).toEqual( expect(eventConsumers!).toEqual({
new EventConsumersDto(2, [ items: [
createEventConsumer(12), createEventConsumer(12),
createEventConsumer(13), createEventConsumer(13),
])); ],
});
})); }));
it('should make put request to start event consumer', it('should make put request to start event consumer',

27
frontend/src/app/features/administration/services/event-consumers.service.ts

@ -9,17 +9,14 @@ import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { ApiUrlConfig, hasAnyLink, pretifyError, Resource, ResourceLinks, ResultSet } from '@app/shared'; import { ApiUrlConfig, hasAnyLink, pretifyError, Resource, ResourceLinks } from '@app/shared';
export class EventConsumersDto extends ResultSet<EventConsumerDto> { export class EventConsumerDto implements Resource {
}
export class EventConsumerDto {
public readonly _links: ResourceLinks; public readonly _links: ResourceLinks;
public readonly canStop: boolean;
public readonly canStart: boolean;
public readonly canReset: boolean; public readonly canReset: boolean;
public readonly canStart: boolean;
public readonly canStop: boolean;
constructor(links: ResourceLinks, constructor(links: ResourceLinks,
public readonly name: string, public readonly name: string,
@ -31,12 +28,17 @@ export class EventConsumerDto {
) { ) {
this._links = links; this._links = links;
this.canStop = hasAnyLink(links, 'stop');
this.canStart = hasAnyLink(links, 'start');
this.canReset = hasAnyLink(links, 'reset'); this.canReset = hasAnyLink(links, 'reset');
this.canStart = hasAnyLink(links, 'start');
this.canStop = hasAnyLink(links, 'stop');
} }
} }
export type EventConsumersDto = Readonly<{
// The list of event consumers.
items: ReadonlyArray<EventConsumerDto>;
}>;
@Injectable() @Injectable()
export class EventConsumersService { export class EventConsumersService {
constructor( constructor(
@ -92,10 +94,11 @@ export class EventConsumersService {
} }
} }
function parseEventConsumers(response: { items: any[] } & Resource) { function parseEventConsumers(response: { items: any[] } & Resource): EventConsumersDto {
const items = response.items.map(parseEventConsumer); const { items: list } = response;
const items = list.map(parseEventConsumer);
return new EventConsumersDto(items.length, items, response._links); return { items };
} }
function parseEventConsumer(response: any): EventConsumerDto { function parseEventConsumer(response: any): EventConsumerDto {

18
frontend/src/app/features/administration/services/users.service.spec.ts

@ -48,11 +48,14 @@ describe('UsersService', () => {
], ],
}); });
expect(users!).toEqual( expect(users!).toEqual({
new UsersDto(100, [ total: 100,
items: [
createUser(12), createUser(12),
createUser(13), createUser(13),
])); ],
canCreate: false,
});
})); }));
it('should make get request with query to get many users', it('should make get request with query to get many users',
@ -76,11 +79,14 @@ describe('UsersService', () => {
], ],
}); });
expect(users!).toEqual( expect(users!).toEqual({
new UsersDto(100, [ total: 100,
items: [
createUser(12), createUser(12),
createUser(13), createUser(13),
])); ],
canCreate: false,
});
})); }));
it('should make get request to get single user', it('should make get request to get single user',

54
frontend/src/app/features/administration/services/users.service.ts

@ -9,21 +9,15 @@ import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { ApiUrlConfig, hasAnyLink, pretifyError, Resource, ResourceLinks, ResultSet } from '@app/shared'; import { ApiUrlConfig, hasAnyLink, pretifyError, Resource, ResourceLinks } from '@app/shared';
export class UsersDto extends ResultSet<UserDto> { export class UserDto implements Resource {
public get canCreate() {
return hasAnyLink(this._links, 'create');
}
}
export class UserDto {
public readonly _links: ResourceLinks; public readonly _links: ResourceLinks;
public readonly canDelete: boolean;
public readonly canLock: boolean; public readonly canLock: boolean;
public readonly canUnlock: boolean; public readonly canUnlock: boolean;
public readonly canUpdate: boolean; public readonly canUpdate: boolean;
public readonly canDelete: boolean;
constructor(links: ResourceLinks, constructor(links: ResourceLinks,
public readonly id: string, public readonly id: string,
@ -34,20 +28,37 @@ export class UserDto {
) { ) {
this._links = links; this._links = links;
this.canDelete = hasAnyLink(links, 'delete');
this.canLock = hasAnyLink(links, 'lock'); this.canLock = hasAnyLink(links, 'lock');
this.canUnlock = hasAnyLink(links, 'unlock'); this.canUnlock = hasAnyLink(links, 'unlock');
this.canUpdate = hasAnyLink(links, 'update'); this.canUpdate = hasAnyLink(links, 'update');
this.canDelete = hasAnyLink(links, 'delete');
} }
} }
type Permissions = readonly string[]; export type UsersDto = Readonly<{
// The list of users.
items: ReadonlyArray<UserDto>;
// The number of users.
total: number;
// True, if the user has permissions to create a user.
canCreate?: boolean;
}>;
export type CreateUserDto = export type UpsertUserDto = Readonly<{
Readonly<{ email: string; displayName: string; permissions: Permissions; password: string }>; // The email address of the user.
email: string;
export type UpdateUserDto = // The display name.
Partial<CreateUserDto>; displayName?: string;
// The permissions as in the dot-notation.
permissions?: ReadonlyArray<string>;
// The password (confirm is only used in the UI).
password?: string;
}>;
@Injectable() @Injectable()
export class UsersService { export class UsersService {
@ -77,7 +88,7 @@ export class UsersService {
pretifyError('i18n:users.loadUserFailed')); pretifyError('i18n:users.loadUserFailed'));
} }
public postUser(dto: CreateUserDto): Observable<UserDto> { public postUser(dto: UpsertUserDto): Observable<UserDto> {
const url = this.apiUrl.buildUrl('api/user-management'); const url = this.apiUrl.buildUrl('api/user-management');
return this.http.post(url, dto).pipe( return this.http.post(url, dto).pipe(
@ -87,7 +98,7 @@ export class UsersService {
pretifyError('i18n:users.createFailed')); pretifyError('i18n:users.createFailed'));
} }
public putUser(user: Resource, dto: UpdateUserDto): Observable<UserDto> { public putUser(user: Resource, dto: Partial<UpsertUserDto>): Observable<UserDto> {
const link = user._links['update']; const link = user._links['update'];
const url = this.apiUrl.buildUrl(link.href); const url = this.apiUrl.buildUrl(link.href);
@ -133,10 +144,13 @@ export class UsersService {
} }
} }
function parseUsers(response: { items: any[]; total: number } & Resource) { function parseUsers(response: { items: any[]; total: number } & Resource): UsersDto {
const items = response.items.map(parseUser); const { items: list, total, _links } = response;
const items = list.map(parseUser);
const canCreate = hasAnyLink(_links, 'create');
return new UsersDto(response.total, items, response._links); return { items, total, canCreate };
} }
function parseUser(response: any) { function parseUser(response: any) {

8
frontend/src/app/features/administration/state/event-consumers.state.spec.ts

@ -8,7 +8,7 @@
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 { EventConsumersDto, EventConsumersService } from '@app/features/administration/internal'; import { EventConsumersService } from '@app/features/administration/internal';
import { DialogService } from '@app/framework'; import { DialogService } from '@app/framework';
import { createEventConsumer } from './../services/event-consumers.service.spec'; import { createEventConsumer } from './../services/event-consumers.service.spec';
import { EventConsumersState } from './event-consumers.state'; import { EventConsumersState } from './event-consumers.state';
@ -35,7 +35,7 @@ describe('EventConsumersState', () => {
describe('Loading', () => { describe('Loading', () => {
it('should load event consumers', () => { it('should load event consumers', () => {
eventConsumersService.setup(x => x.getEventConsumers()) eventConsumersService.setup(x => x.getEventConsumers())
.returns(() => of(new EventConsumersDto(2, [eventConsumer1, eventConsumer2], {}))).verifiable(); .returns(() => of({ items: [eventConsumer1, eventConsumer2] })).verifiable();
eventConsumersState.load().subscribe(); eventConsumersState.load().subscribe();
@ -57,7 +57,7 @@ describe('EventConsumersState', () => {
it('should show notification on load if reload is true', () => { it('should show notification on load if reload is true', () => {
eventConsumersService.setup(x => x.getEventConsumers()) eventConsumersService.setup(x => x.getEventConsumers())
.returns(() => of(new EventConsumersDto(2, [eventConsumer1, eventConsumer2], {}))).verifiable(); .returns(() => of({ items: [eventConsumer1, eventConsumer2] })).verifiable();
eventConsumersState.load(true).subscribe(); eventConsumersState.load(true).subscribe();
@ -81,7 +81,7 @@ describe('EventConsumersState', () => {
describe('Updates', () => { describe('Updates', () => {
beforeEach(() => { beforeEach(() => {
eventConsumersService.setup(x => x.getEventConsumers()) eventConsumersService.setup(x => x.getEventConsumers())
.returns(() => of(new EventConsumersDto(2, [eventConsumer1, eventConsumer2], {}))).verifiable(); .returns(() => of({ items: [eventConsumer1, eventConsumer2] })).verifiable();
eventConsumersState.load().subscribe(); eventConsumersState.load().subscribe();
}); });

4
frontend/src/app/features/administration/state/users.forms.ts

@ -7,9 +7,9 @@
import { FormControl, Validators } from '@angular/forms'; import { FormControl, Validators } from '@angular/forms';
import { ExtendedFormGroup, Form, ValidatorsEx } from '@app/shared'; import { ExtendedFormGroup, Form, ValidatorsEx } from '@app/shared';
import { UpdateUserDto, UserDto } from './../services/users.service'; import { UpsertUserDto, UserDto } from './../services/users.service';
export class UserForm extends Form<ExtendedFormGroup, UpdateUserDto, UserDto> { export class UserForm extends Form<ExtendedFormGroup, UpsertUserDto, UserDto> {
constructor() { constructor() {
super(new ExtendedFormGroup({ super(new ExtendedFormGroup({
email: new FormControl('', [ email: new FormControl('', [

26
frontend/src/app/features/administration/state/users.state.spec.ts

@ -8,7 +8,7 @@
import { firstValueFrom, of, throwError } from 'rxjs'; import { firstValueFrom, 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 { UsersDto, UsersService } from '@app/features/administration/internal'; import { UpsertUserDto, UsersService } from '@app/features/administration/internal';
import { DialogService } from '@app/shared'; import { DialogService } from '@app/shared';
import { createUser } from './../services/users.service.spec'; import { createUser } from './../services/users.service.spec';
import { UsersState } from './users.state'; import { UsersState } from './users.state';
@ -17,8 +17,6 @@ describe('UsersState', () => {
const user1 = createUser(1); const user1 = createUser(1);
const user2 = createUser(2); const user2 = createUser(2);
const oldUsers = new UsersDto(200, [user1, user2]);
const newUser = createUser(3); const newUser = createUser(3);
let dialogs: IMock<DialogService>; let dialogs: IMock<DialogService>;
@ -39,7 +37,7 @@ describe('UsersState', () => {
describe('Loading', () => { describe('Loading', () => {
it('should load users', () => { it('should load users', () => {
usersService.setup(x => x.getUsers(10, 0, undefined)) usersService.setup(x => x.getUsers(10, 0, undefined))
.returns(() => of(oldUsers)).verifiable(); .returns(() => of({ items: [user1, user2], total: 200 })).verifiable();
usersState.load().subscribe(); usersState.load().subscribe();
@ -62,7 +60,7 @@ describe('UsersState', () => {
it('should show notification on load if reload is true', () => { it('should show notification on load if reload is true', () => {
usersService.setup(x => x.getUsers(10, 0, undefined)) usersService.setup(x => x.getUsers(10, 0, undefined))
.returns(() => of(oldUsers)).verifiable(); .returns(() => of({ items: [user1, user2], total: 200 })).verifiable();
usersState.load(true).subscribe(); usersState.load(true).subscribe();
@ -73,7 +71,7 @@ describe('UsersState', () => {
it('should load with new pagination if paging', () => { it('should load with new pagination if paging', () => {
usersService.setup(x => x.getUsers(10, 10, undefined)) usersService.setup(x => x.getUsers(10, 10, undefined))
.returns(() => of(new UsersDto(200, []))).verifiable(); .returns(() => of({ items: [], total: 200 })).verifiable();
usersState.page({ page: 1, pageSize: 10 }).subscribe(); usersState.page({ page: 1, pageSize: 10 }).subscribe();
@ -82,7 +80,7 @@ describe('UsersState', () => {
it('should load with query if searching', () => { it('should load with query if searching', () => {
usersService.setup(x => x.getUsers(10, 0, 'my-query')) usersService.setup(x => x.getUsers(10, 0, 'my-query'))
.returns(() => of(new UsersDto(0, []))).verifiable(); .returns(() => of({ items: [], total: 0 })).verifiable();
usersState.search('my-query').subscribe(); usersState.search('my-query').subscribe();
@ -93,7 +91,7 @@ describe('UsersState', () => {
describe('Updates', () => { describe('Updates', () => {
beforeEach(() => { beforeEach(() => {
usersService.setup(x => x.getUsers(10, 0, undefined)) usersService.setup(x => x.getUsers(10, 0, undefined))
.returns(() => of(oldUsers)).verifiable(); .returns(() => of({ items: [user1, user2], total: 200 })).verifiable();
usersState.load().subscribe(); usersState.load().subscribe();
}); });
@ -133,7 +131,7 @@ describe('UsersState', () => {
}); });
it('should add user to snapshot if created', () => { it('should add user to snapshot if created', () => {
const request = { ...newUser, password: 'password' }; const request: UpsertUserDto = { ...newUser, password: 'password' } as any;
usersService.setup(x => x.postUser(request)) usersService.setup(x => x.postUser(request))
.returns(() => of(newUser)).verifiable(); .returns(() => of(newUser)).verifiable();
@ -145,7 +143,7 @@ describe('UsersState', () => {
}); });
it('should update user if updated', () => { it('should update user if updated', () => {
const request = {}; const request: Partial<UpsertUserDto> = {};
const updated = createUser(2, '_new'); const updated = createUser(2, '_new');
@ -190,10 +188,10 @@ describe('UsersState', () => {
}); });
it('should truncate users if page size reached', () => { it('should truncate users if page size reached', () => {
const request = { ...newUser, password: 'password' }; const request: UpsertUserDto = { ...newUser, password: 'password' } as any;
usersService.setup(x => x.getUsers(2, 0, undefined)) usersService.setup(x => x.getUsers(2, 0, undefined))
.returns(() => of(new UsersDto(200, [user1, user2]))).verifiable(); .returns(() => of({ items: [user1, user2], total: 200 })).verifiable();
usersService.setup(x => x.postUser(request)) usersService.setup(x => x.postUser(request))
.returns(() => of(newUser)).verifiable(); .returns(() => of(newUser)).verifiable();
@ -209,7 +207,7 @@ describe('UsersState', () => {
describe('Selection', () => { describe('Selection', () => {
beforeEach(() => { beforeEach(() => {
usersService.setup(x => x.getUsers(10, 0, undefined)) usersService.setup(x => x.getUsers(10, 0, undefined))
.returns(() => of(oldUsers)).verifiable(Times.atLeastOnce()); .returns(() => of({ items: [user1, user2], total: 200 })).verifiable(Times.atLeastOnce());
usersState.load().subscribe(); usersState.load().subscribe();
usersState.select(user2.id).subscribe(); usersState.select(user2.id).subscribe();
@ -222,7 +220,7 @@ describe('UsersState', () => {
]; ];
usersService.setup(x => x.getUsers(10, 0, undefined)) usersService.setup(x => x.getUsers(10, 0, undefined))
.returns(() => of(new UsersDto(2, newUsers))); .returns(() => of({ items: newUsers, total: 200 }));
usersState.load().subscribe(); usersState.load().subscribe();

6
frontend/src/app/features/administration/state/users.state.ts

@ -12,7 +12,7 @@ import '@app/framework/utils/rxjs-extensions';
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 { DialogService, getPagingInfo, ListState, shareSubscribed, State } from '@app/shared'; import { DialogService, getPagingInfo, ListState, shareSubscribed, State } from '@app/shared';
import { CreateUserDto, UpdateUserDto, UserDto, UsersService } from './../services/users.service'; import { UpsertUserDto, UserDto, UsersService } from './../services/users.service';
interface Snapshot extends ListState<string> { interface Snapshot extends ListState<string> {
// The current users. // The current users.
@ -133,7 +133,7 @@ export class UsersState extends State<Snapshot> {
shareSubscribed(this.dialogs)); shareSubscribed(this.dialogs));
} }
public create(request: CreateUserDto): Observable<UserDto> { public create(request: UpsertUserDto): Observable<UserDto> {
return this.usersService.postUser(request).pipe( return this.usersService.postUser(request).pipe(
tap(created => { tap(created => {
this.next(s => { this.next(s => {
@ -145,7 +145,7 @@ export class UsersState extends State<Snapshot> {
shareSubscribed(this.dialogs, { silent: true })); shareSubscribed(this.dialogs, { silent: true }));
} }
public update(user: UserDto, request: UpdateUserDto): Observable<UserDto> { public update(user: UserDto, request: Partial<UpsertUserDto>): Observable<UserDto> {
return this.usersService.putUser(user, request).pipe( return this.usersService.putUser(user, request).pipe(
tap(updated => { tap(updated => {
this.replaceUser(updated); this.replaceUser(updated);

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

@ -31,7 +31,7 @@ export class GraphQLPageComponent implements AfterViewInit {
public ngAfterViewInit() { public ngAfterViewInit() {
const url = this.apiUrl.buildUrl(`api/content/${this.appsState.appName}/graphql`); const url = this.apiUrl.buildUrl(`api/content/${this.appsState.appName}/graphql`);
const subscriptionUrl = const subscriptionUrl =
url url
.replace('http://', 'ws://') .replace('http://', 'ws://')
.replace('https://', 'wss://') + .replace('https://', 'wss://') +

2
frontend/src/app/features/content/pages/content/content-page.component.ts

@ -78,7 +78,7 @@ export class ContentPageComponent extends ResourceOwner implements CanComponentD
this.own( this.own(
this.languagesState.isoLanguages this.languagesState.isoLanguages
.subscribe(languages => { .subscribe(languages => {
this.languages = languages; this.languages = languages;
})); }));

2
frontend/src/app/features/content/pages/content/editor/content-field.component.ts

@ -113,7 +113,7 @@ export class ContentFieldComponent implements OnChanges {
if (!master) { if (!master) {
return; return;
} }
const masterCode = master.iso2Code; const masterCode = master.iso2Code;
const masterValue = this.formModel.get(masterCode)!.form.value; const masterValue = this.formModel.get(masterCode)!.form.value;

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

@ -10,7 +10,7 @@
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 { distinctUntilChanged, map, switchMap, take, tap } from 'rxjs/operators'; import { distinctUntilChanged, map, switchMap, take, tap } from 'rxjs/operators';
import { AppLanguageDto, AppsState, contentsTranslationStatus, ContentDto, ContentsState, ContributorsState, defined, LanguagesState, LocalStoreService, ModalModel, Queries, Query, QuerySynchronizer, ResourceOwner, Router2State, SchemaDto, SchemasService, SchemasState, Settings, switchSafe, TableSettings, TempService, TranslationStatus, UIState } from '@app/shared'; import { AppLanguageDto, AppsState, ContentDto, ContentsState, contentsTranslationStatus, ContributorsState, defined, LanguagesState, LocalStoreService, ModalModel, Queries, Query, QuerySynchronizer, ResourceOwner, Router2State, SchemaDto, SchemasService, SchemasState, Settings, switchSafe, TableSettings, TempService, TranslationStatus, UIState } from '@app/shared';
import { DueTimeSelectorComponent } from './../../shared/due-time-selector.component'; import { DueTimeSelectorComponent } from './../../shared/due-time-selector.component';
@Component({ @Component({
@ -119,7 +119,7 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit {
this.contentsState.contents this.contentsState.contents
.subscribe(contents => { .subscribe(contents => {
this.updateSelectionSummary(); this.updateSelectionSummary();
this.translationStatus = contentsTranslationStatus(contents.map(x => x.data), this.schema, this.languages); this.translationStatus = contentsTranslationStatus(contents.map(x => x.data), this.schema, this.languages);
})); }));
} }

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

@ -49,7 +49,7 @@ export class ArrayEditorComponent implements OnChanges {
@ViewChildren(ArrayItemComponent) @ViewChildren(ArrayItemComponent)
public children!: QueryList<ArrayItemComponent>; public children!: QueryList<ArrayItemComponent>;
@ViewChildren(VirtualScrollerComponent) @ViewChildren(VirtualScrollerComponent)
public scroller?: QueryList<VirtualScrollerComponent>; public scroller?: QueryList<VirtualScrollerComponent>;

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

@ -143,7 +143,7 @@ export class ArrayItemComponent implements OnChanges {
function getTitle(formModel: ObjectFormBase) { function getTitle(formModel: ObjectFormBase) {
const value = formModel.form.value; const value = formModel.form.value;
const values: string[] = []; const values: string[] = [];
let valueLength = 0; let valueLength = 0;
if (Types.is(formModel, ComponentForm) && formModel.schema) { if (Types.is(formModel, ComponentForm) && formModel.schema) {
@ -163,7 +163,7 @@ function getTitle(formModel: ObjectFormBase) {
if (formatted) { if (formatted) {
values.push(formatted); values.push(formatted);
valueLength += formatted.length; valueLength += formatted.length;
if (valueLength > 30) { if (valueLength > 30) {
break; break;
} }

2
frontend/src/app/features/content/shared/forms/component.component.ts

@ -58,7 +58,7 @@ export class ComponentComponent extends ResourceOwner implements OnChanges {
public ngOnChanges(changes: SimpleChanges) { public ngOnChanges(changes: SimpleChanges) {
if (changes['formModel']) { if (changes['formModel']) {
this.unsubscribeAll(); this.unsubscribeAll();
this.isDisabled = disabled$(this.formModel.form); this.isDisabled = disabled$(this.formModel.form);
this.own( this.own(

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

@ -34,7 +34,7 @@
[formValue]="form.valueChanges | async" [formValue]="form.valueChanges | async"
[formIndex]="index" [formIndex]="index"
[formField]="formModel.field.name" [formField]="formModel.field.name"
[language]="language?.iso2Code"> [language]="language.iso2Code">
</sqx-iframe-editor> </sqx-iframe-editor>
</ng-container> </ng-container>

2
frontend/src/app/features/content/shared/references/reference-dropdown.component.ts

@ -201,7 +201,7 @@ export class ReferenceDropdownComponent extends StatefulControlComponent<State,
this.next({ contentNames }); this.next({ contentNames });
} }
this.next({ isLoading: false }); this.next({ isLoading: false });
} }
} }

6
frontend/src/app/features/content/shared/references/reference-item.component.ts

@ -8,7 +8,7 @@
/* tslint:disable: component-selector */ /* tslint:disable: component-selector */
import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core'; import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core';
import { AppLanguageDto, ContentDto, getContentValue, MetaFields } from '@app/shared'; import { AppLanguageDto, ContentDto, getContentValue, META_FIELDS } from '@app/shared';
@Component({ @Component({
selector: '[sqxReferenceItem][language]', selector: '[sqxReferenceItem][language]',
@ -17,8 +17,8 @@ import { AppLanguageDto, ContentDto, getContentValue, MetaFields } from '@app/sh
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class ReferenceItemComponent implements OnChanges { export class ReferenceItemComponent implements OnChanges {
public readonly metaFields = MetaFields; public readonly metaFields = META_FIELDS;
@Output() @Output()
public delete = new EventEmitter(); public delete = new EventEmitter();

2
frontend/src/app/features/content/shared/references/references-tags.component.ts

@ -170,7 +170,7 @@ export class ReferencesTagsComponent extends StatefulControlComponent<State, Rea
this.next({ converter }); this.next({ converter });
} }
this.next({ isLoading: false }); this.next({ isLoading: false });
} }
} }

2
frontend/src/app/features/rules/pages/messages.ts

@ -7,7 +7,7 @@
export class RuleConfigured { export class RuleConfigured {
constructor( constructor(
public readonly trigger: any, public readonly trigger: any,
public readonly action: any, public readonly action: any,
) { ) {
} }

2
frontend/src/app/features/rules/pages/rule/rule-page.component.ts

@ -183,7 +183,7 @@ export class RulePageComponent extends ResourceOwner implements OnInit {
return; return;
} }
if (!this.currentAction.form.form.valid || !this.currentTrigger.form.form.valid) { if (!this.currentAction.form.form.valid || !this.currentTrigger.form.form.valid) {
return; return;
} }

4
frontend/src/app/features/schemas/pages/schema/ui/field-list.component.ts

@ -7,9 +7,9 @@
import { CdkDragDrop, moveItemInArray, transferArrayItem } from '@angular/cdk/drag-drop'; import { CdkDragDrop, moveItemInArray, transferArrayItem } from '@angular/cdk/drag-drop';
import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core'; import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core';
import { MetaFields, SchemaDto, TableField } from '@app/shared'; import { META_FIELDS, SchemaDto, TableField } from '@app/shared';
const META_FIELD_NAMES = Object.values(MetaFields).filter(x => x !== MetaFields.empty); const META_FIELD_NAMES = Object.values(META_FIELDS).filter(x => x !== META_FIELDS.empty);
@Component({ @Component({
selector: 'sqx-field-list[fieldNames][schema]', selector: 'sqx-field-list[fieldNames][schema]',

2
frontend/src/app/features/settings/pages/backups/backup.component.ts

@ -21,7 +21,7 @@ export class BackupComponent implements OnChanges {
public duration = ''; public duration = '';
constructor( constructor(
public readonly apiUrl: ApiUrlConfig, public readonly apiUrl: ApiUrlConfig,
private readonly backupsState: BackupsState, private readonly backupsState: BackupsState,
) { ) {
} }

8
frontend/src/app/features/settings/pages/templates/template.component.ts

@ -20,7 +20,7 @@ export class TemplateComponent implements OnChanges {
public template!: TemplateDto; public template!: TemplateDto;
public isExpanded = false; public isExpanded = false;
public details?: Observable<string>; public details?: Observable<string>;
constructor( constructor(
@ -44,14 +44,14 @@ export class TemplateComponent implements OnChanges {
let details = dto.details.replace(/<APP>/g, app); let details = dto.details.replace(/<APP>/g, app);
const client = this.clientsState.snapshot.clients[0]; const client = this.clientsState.snapshot.clients[0];
if (client) { if (client) {
const clientId = `${app}:${client.id}`; const clientId = `${app}:${client.id}`;
details = details.replace(/\<CLIENT_ID>/g, clientId); details = details.replace(/\<CLIENT_ID>/g, clientId);
details = details.replace(/\<CLIENT_SECRET>/g, client.secret); details = details.replace(/\<CLIENT_SECRET>/g, client.secret);
} }
return details; return details;
} }
} }

4
frontend/src/app/features/settings/pages/workflows/workflow.component.ts

@ -58,7 +58,7 @@ export class WorkflowComponent implements OnChanges {
.subscribe({ .subscribe({
next: () => { next: () => {
this.error = null; this.error = null;
}, },
error: (error: ErrorDto) => { error: (error: ErrorDto) => {
this.error = error; this.error = error;
}, },
@ -79,7 +79,7 @@ export class WorkflowComponent implements OnChanges {
} }
public rename(name: string) { public rename(name: string) {
this.workflow = this.workflow.rename(name); this.workflow = this.workflow.changeName(name);
} }
public changeSchemaIds(schemaIds: string[]) { public changeSchemaIds(schemaIds: string[]) {

2
frontend/src/app/framework/angular/compensate-scrollbar.directive.ts

@ -16,7 +16,7 @@ export class CompensateScrollbarDirective extends ResourceOwner implements Resiz
@Input('sqxCompensateScrollbar') @Input('sqxCompensateScrollbar')
public enabled?: string | boolean | null = true; public enabled?: string | boolean | null = true;
constructor( constructor(
private readonly renderer: Renderer2, private readonly renderer: Renderer2,
private readonly element: ElementRef<HTMLElement>, private readonly element: ElementRef<HTMLElement>,

2
frontend/src/app/framework/angular/forms/editors/code-editor.component.ts

@ -107,7 +107,7 @@ export class CodeEditorComponent extends StatefulControlComponent<{}, string> im
} catch { } catch {
this.value = ''; this.value = '';
} }
if (this.aceEditor) { if (this.aceEditor) {
this.setValue(this.value); this.setValue(this.value);
} }

2
frontend/src/app/framework/angular/forms/editors/tag-editor.stories.ts

@ -110,7 +110,7 @@ Suggestions.args = {
export const SuggestionsEmpty = Template.bind({}); export const SuggestionsEmpty = Template.bind({});
SuggestionsEmpty.args = { SuggestionsEmpty.args = {
suggestions: [], suggestions: [],
allowOpen: true, allowOpen: true,
}; };

2
frontend/src/app/framework/angular/if-once.directive.ts

@ -17,7 +17,7 @@ export class IfOnceDirective {
public set condition(value: boolean) { public set condition(value: boolean) {
if (value && !this.hasView) { if (value && !this.hasView) {
this.viewContainer.createEmbeddedView(this.templateRef); this.viewContainer.createEmbeddedView(this.templateRef);
this.hasView = true; this.hasView = true;
} }
} }

1
frontend/src/app/framework/internal.ts

@ -10,7 +10,6 @@ export * from './angular/drag-helper';
export * from './angular/routers/router-utils'; export * from './angular/routers/router-utils';
export * from './angular/stateful.component'; export * from './angular/stateful.component';
export * from './configurations'; export * from './configurations';
export * from './services/analytics.service';
export * from './services/clipboard.service'; export * from './services/clipboard.service';
export * from './services/dialog.service'; export * from './services/dialog.service';
export * from './services/loading.service'; export * from './services/loading.service';

3
frontend/src/app/framework/module.ts

@ -11,7 +11,7 @@ import { ModuleWithProviders, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { ColorPickerModule } from 'ngx-color-picker'; import { ColorPickerModule } from 'ngx-color-picker';
import { AnalyticsService, AutocompleteComponent, AvatarComponent, CachingInterceptor, CanDeactivateGuard, CheckboxGroupComponent, ClipboardService, CodeComponent, CodeEditorComponent, ColorPickerComponent, CompensateScrollbarDirective, ConfirmClickDirective, ControlErrorsComponent, ControlErrorsMessagesComponent, CopyDirective, DarkenPipe, DatePipe, DateTimeEditorComponent, DayOfWeekPipe, DayPipe, DialogRendererComponent, DialogService, DisplayNamePipe, DropdownComponent, DropdownMenuComponent, DurationPipe, EditableTitleComponent, ExternalLinkDirective, FileDropDirective, FileSizePipe, FocusOnInitDirective, FormAlertComponent, FormErrorComponent, FormHintComponent, FromNowPipe, FullDateTimePipe, HighlightPipe, HoverBackgroundDirective, IfOnceDirective, ImageSourceDirective, ImageUrlDirective, IndeterminateValueDirective, ISODatePipe, JoinPipe, KeysPipe, KNumberPipe, LanguageSelectorComponent, LayoutComponent, LayoutContainerDirective, LightenPipe, ListViewComponent, LoaderComponent, LoadingInterceptor, LoadingService, LocalizedInputComponent, LocalStoreService, MarkdownDirective, MarkdownInlinePipe, MarkdownPipe, MessageBus, ModalDialogComponent, ModalDirective, ModalPlacementDirective, MonthPipe, OnboardingService, OnboardingTooltipComponent, PagerComponent, ParentLinkDirective, ProgressBarComponent, RadioGroupComponent, ResizedDirective, ResizeService, ResourceLoaderService, RootViewComponent, SafeHtmlPipe, SafeResourceUrlPipe, SafeUrlPipe, ScrollActiveDirective, ShortcutComponent, ShortcutDirective, ShortcutService, ShortDatePipe, ShortTimePipe, StarsComponent, StatusIconComponent, StopClickDirective, StopDragDirective, SyncScollingDirective, SyncWidthDirective, TabRouterlinkDirective, TagEditorComponent, TemplateWrapperDirective, TempService, TitleComponent, TitleService, ToggleComponent, ToolbarComponent, TooltipDirective, TransformInputDirective, TranslatePipe, VideoPlayerComponent } from './declarations'; import { AutocompleteComponent, AvatarComponent, CachingInterceptor, CanDeactivateGuard, CheckboxGroupComponent, ClipboardService, CodeComponent, CodeEditorComponent, ColorPickerComponent, CompensateScrollbarDirective, ConfirmClickDirective, ControlErrorsComponent, ControlErrorsMessagesComponent, CopyDirective, DarkenPipe, DatePipe, DateTimeEditorComponent, DayOfWeekPipe, DayPipe, DialogRendererComponent, DialogService, DisplayNamePipe, DropdownComponent, DropdownMenuComponent, DurationPipe, EditableTitleComponent, ExternalLinkDirective, FileDropDirective, FileSizePipe, FocusOnInitDirective, FormAlertComponent, FormErrorComponent, FormHintComponent, FromNowPipe, FullDateTimePipe, HighlightPipe, HoverBackgroundDirective, IfOnceDirective, ImageSourceDirective, ImageUrlDirective, IndeterminateValueDirective, ISODatePipe, JoinPipe, KeysPipe, KNumberPipe, LanguageSelectorComponent, LayoutComponent, LayoutContainerDirective, LightenPipe, ListViewComponent, LoaderComponent, LoadingInterceptor, LoadingService, LocalizedInputComponent, LocalStoreService, MarkdownDirective, MarkdownInlinePipe, MarkdownPipe, MessageBus, ModalDialogComponent, ModalDirective, ModalPlacementDirective, MonthPipe, OnboardingService, OnboardingTooltipComponent, PagerComponent, ParentLinkDirective, ProgressBarComponent, RadioGroupComponent, ResizedDirective, ResizeService, ResourceLoaderService, RootViewComponent, SafeHtmlPipe, SafeResourceUrlPipe, SafeUrlPipe, ScrollActiveDirective, ShortcutComponent, ShortcutDirective, ShortcutService, ShortDatePipe, ShortTimePipe, StarsComponent, StatusIconComponent, StopClickDirective, StopDragDirective, SyncScollingDirective, SyncWidthDirective, TabRouterlinkDirective, TagEditorComponent, TemplateWrapperDirective, TempService, TitleComponent, TitleService, ToggleComponent, ToolbarComponent, TooltipDirective, TransformInputDirective, TranslatePipe, VideoPlayerComponent } from './declarations';
@NgModule({ @NgModule({
imports: [ imports: [
@ -205,7 +205,6 @@ export class SqxFrameworkModule {
return { return {
ngModule: SqxFrameworkModule, ngModule: SqxFrameworkModule,
providers: [ providers: [
AnalyticsService,
CanDeactivateGuard, CanDeactivateGuard,
ClipboardService, ClipboardService,
DialogService, DialogService,

68
frontend/src/app/framework/services/analytics.service.ts

@ -1,68 +0,0 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Injectable } from '@angular/core';
import { NavigationEnd, Router } from '@angular/router';
import { filter } from 'rxjs';
import { UIOptions } from './../configurations';
import { Types } from './../utils/types';
import { ResourceLoaderService } from './resource-loader.service';
@Injectable()
export class AnalyticsService {
private readonly gtag: any;
private readonly analyticsId?: string;
constructor(
private readonly uiOptions?: UIOptions,
private readonly router?: Router,
private readonly resourceLoader?: ResourceLoaderService,
) {
window['dataLayer'] = window['dataLayer'] || [];
// eslint-disable-next-line func-names
this.gtag = function () {
// eslint-disable-next-line prefer-rest-params
window['dataLayer'].push(arguments);
};
if (this.uiOptions) {
this.analyticsId = this.uiOptions.get('google.analyticsId');
}
this.configureGtag();
}
public trackEvent(category: string, action: string, label?: string, value?: number) {
this.gtag('event', 'user-action', {
event_category: category,
event_action: action,
event_label: label,
value,
});
}
private configureGtag() {
if (this.analyticsId && this.router && this.resourceLoader && window.location.hostname !== 'localhost') {
this.gtag('config', this.analyticsId, { anonymize_ip: true });
this.router.events.pipe(
filter(event => Types.is(event, NavigationEnd)))
.subscribe(() => {
this.gtag('config', this.analyticsId, { page_path: window.location.pathname, anonymize_ip: true });
});
this.loadScript();
}
}
private loadScript() {
if (document.cookie.indexOf('ga-disable') < 0 && this.resourceLoader) {
this.resourceLoader.loadScript(`https://www.googletagmanager.com/gtag/js?id=${this.analyticsId}`);
}
}
}

2
frontend/src/app/framework/services/dialog.service.ts

@ -65,7 +65,7 @@ export class Tooltip {
public get offsetX() { public get offsetX() {
return this.isHorizontal ? 6 : 0; return this.isHorizontal ? 6 : 0;
} }
public get offsetY() { public get offsetY() {
return this.isHorizontal ? 0 : 6; return this.isHorizontal ? 0 : 6;
} }

19
frontend/src/app/framework/state.ts

@ -7,7 +7,6 @@
import { BehaviorSubject, combineLatest, Observable } from 'rxjs'; import { BehaviorSubject, combineLatest, Observable } from 'rxjs';
import { distinctUntilChanged, map, shareReplay } from 'rxjs/operators'; import { distinctUntilChanged, map, shareReplay } from 'rxjs/operators';
import { ResourceLinks } from './utils/hateos';
import { Types } from './utils/types'; import { Types } from './utils/types';
export type Mutable<T> = { export type Mutable<T> = {
@ -44,18 +43,6 @@ export class Model<T> {
} }
} }
export class ResultSet<T> {
public readonly _links: ResourceLinks;
constructor(
public readonly total: number,
public readonly items: ReadonlyArray<T>,
links?: ResourceLinks,
) {
this._links = links || {};
}
}
export interface PagingInfo { export interface PagingInfo {
// The current page. // The current page.
page: number; page: number;
@ -101,7 +88,7 @@ const devToolsExtension = window['__REDUX_DEVTOOLS_EXTENSION__'];
export class State<T extends {}> { export class State<T extends {}> {
private readonly state: BehaviorSubject<Readonly<T>>; private readonly state: BehaviorSubject<Readonly<T>>;
private readonly devTools?: any; private readonly devTools?: any;
public get changes(): Observable<Readonly<T>> { public get changes(): Observable<Readonly<T>> {
return this.state; return this.state;
} }
@ -135,8 +122,8 @@ export class State<T extends {}> {
const name = `[Squidex] ${debugName}`; const name = `[Squidex] ${debugName}`;
this.devTools = devToolsExtension.connect({ name, features: { jump: true } }); this.devTools = devToolsExtension.connect({ name, features: { jump: true } });
this.devTools.init(initialState); this.devTools.init(initialState);
this.devTools.subscribe((message: any) => { this.devTools.subscribe((message: any) => {
if (message.type === 'DISPATCH' && message.payload.type === 'JUMP_TO_ACTION') { if (message.type === 'DISPATCH' && message.payload.type === 'JUMP_TO_ACTION') {
this.state.next(JSON.parse(message.state)); this.state.next(JSON.parse(message.state));

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

@ -13,7 +13,7 @@ export type AnchorX =
'left-to-left' | 'left-to-left' |
'right-to-left' | 'right-to-left' |
'right-to-right'; 'right-to-right';
export type AnchorY = export type AnchorY =
'bottom-to-bottom' | 'bottom-to-bottom' |
'bottom-to-top' | 'bottom-to-top' |

4
frontend/src/app/framework/utils/text-measurer.ts

@ -35,7 +35,7 @@ export class TextMeasurer {
} }
const style = window.getComputedStyle(currentElement); const style = window.getComputedStyle(currentElement);
const fontSize = style.getPropertyValue('font-size'); const fontSize = style.getPropertyValue('font-size');
const fontFamily = style.getPropertyValue('font-family'); const fontFamily = style.getPropertyValue('font-family');
@ -45,7 +45,7 @@ export class TextMeasurer {
this.font = `${fontSize} ${fontFamily}`; this.font = `${fontSize} ${fontFamily}`;
} }
if (!this.font) { if (!this.font) {
return -1000; return -1000;
} }

2
frontend/src/app/shared/components/app-form.component.ts

@ -38,7 +38,7 @@ export class AppFormComponent {
if (value) { if (value) {
const request = { ...value, template: this.template?.name }; const request = { ...value, template: this.template?.name };
this.appsStore.create(request) this.appsStore.create(request)
.subscribe({ .subscribe({
next: () => { next: () => {

46
frontend/src/app/shared/components/contents/content-list-cell.directive.ts

@ -7,7 +7,7 @@
import { Directive, ElementRef, Input, OnChanges, OnDestroy, OnInit, Pipe, PipeTransform, Renderer2 } from '@angular/core'; import { Directive, ElementRef, Input, OnChanges, OnDestroy, OnInit, Pipe, PipeTransform, Renderer2 } from '@angular/core';
import { ResourceOwner } from '@app/framework'; import { ResourceOwner } from '@app/framework';
import { ContentDto, FieldSizes, MetaFields, TableField, TableSettings } from '@app/shared/internal'; import { ContentDto, FieldSizes, META_FIELDS, TableField, TableSettings } from '@app/shared/internal';
export function getCellWidth(field: TableField, sizes: FieldSizes | undefined | null) { export function getCellWidth(field: TableField, sizes: FieldSizes | undefined | null) {
const size = sizes?.[field.name] || 0; const size = sizes?.[field.name] || 0;
@ -17,27 +17,27 @@ export function getCellWidth(field: TableField, sizes: FieldSizes | undefined |
} }
switch (field) { switch (field) {
case MetaFields.id: case META_FIELDS.id:
return 280; return 280;
case MetaFields.created: case META_FIELDS.created:
return 150; return 150;
case MetaFields.createdByAvatar: case META_FIELDS.createdByAvatar:
return 55; return 55;
case MetaFields.createdByName: case META_FIELDS.createdByName:
return 150; return 150;
case MetaFields.lastModified: case META_FIELDS.lastModified:
return 150; return 150;
case MetaFields.lastModifiedByAvatar: case META_FIELDS.lastModifiedByAvatar:
return 55; return 55;
case MetaFields.lastModifiedByName: case META_FIELDS.lastModifiedByName:
return 150; return 150;
case MetaFields.status: case META_FIELDS.status:
return 200; return 200;
case MetaFields.statusNext: case META_FIELDS.statusNext:
return 240; return 240;
case MetaFields.statusColor: case META_FIELDS.statusColor:
return 50; return 50;
case MetaFields.version: case META_FIELDS.version:
return 80; return 80;
default: default:
return 200; return 200;
@ -96,7 +96,7 @@ export class ContentListWidthDirective extends ResourceOwner implements OnChange
if (!this.fields) { if (!this.fields) {
return; return;
} }
let size = 100; let size = 100;
for (const field of this.fields) { for (const field of this.fields) {
@ -106,7 +106,7 @@ export class ContentListWidthDirective extends ResourceOwner implements OnChange
if (size === this.size) { if (size === this.size) {
return; return;
} }
const width = `${size}px`; const width = `${size}px`;
this.renderer.setStyle(this.element.nativeElement, 'min-width', width); this.renderer.setStyle(this.element.nativeElement, 'min-width', width);
@ -151,13 +151,13 @@ export class ContentListCellDirective extends ResourceOwner implements OnChanges
if (!this.field.name) { if (!this.field.name) {
return; return;
} }
const size = getCellWidth(this.field, this.sizes); const size = getCellWidth(this.field, this.sizes);
if (size === this.size) { if (size === this.size) {
return; return;
} }
const width = `${size}px`; const width = `${size}px`;
this.renderer.setStyle(this.element.nativeElement, 'min-width', width); this.renderer.setStyle(this.element.nativeElement, 'min-width', width);
@ -204,11 +204,11 @@ export class ContentListCellResizeDirective implements OnInit, OnDestroy {
this.mouseDown = undefined; this.mouseDown = undefined;
this.mouseBlur?.(); this.mouseBlur?.();
this.mouseBlur = undefined; this.mouseBlur = undefined;
this.resetMovement(); this.resetMovement();
} }
public ngOnInit() { public ngOnInit() {
if (!this.tableFields || !this.fieldName) { if (!this.tableFields || !this.fieldName) {
return; return;
} }
@ -221,7 +221,7 @@ export class ContentListCellResizeDirective implements OnInit, OnDestroy {
this.mouseDown = this.renderer.listen(this.resizer, 'mousedown', this.onMouseDown); this.mouseDown = this.renderer.listen(this.resizer, 'mousedown', this.onMouseDown);
this.mouseBlur = this.renderer.listen(this.resizer, 'blur', this.onMouseUp); this.mouseBlur = this.renderer.listen(this.resizer, 'blur', this.onMouseUp);
} }
private onMouseDown = (event: MouseEvent) => { private onMouseDown = (event: MouseEvent) => {
if (!this.tableFields || !this.fieldName) { if (!this.tableFields || !this.fieldName) {
return; return;
@ -236,7 +236,7 @@ export class ContentListCellResizeDirective implements OnInit, OnDestroy {
this.startOffset = event.pageX; this.startOffset = event.pageX;
this.startWidth = this.element.nativeElement.offsetWidth; this.startWidth = this.element.nativeElement.offsetWidth;
}; };
private onMouseMove = (event: MouseEvent) => { private onMouseMove = (event: MouseEvent) => {
if (!this.mouseMove || !this.tableFields || !this.fieldName) { if (!this.mouseMove || !this.tableFields || !this.fieldName) {
return; return;
@ -248,7 +248,7 @@ export class ContentListCellResizeDirective implements OnInit, OnDestroy {
this.resetMovement(); this.resetMovement();
} }
}; };
private onMouseUp = (event: MouseEvent) => { private onMouseUp = (event: MouseEvent) => {
if (!this.mouseMove || !this.tableFields || !this.fieldName) { if (!this.mouseMove || !this.tableFields || !this.fieldName) {
return; return;
@ -271,7 +271,7 @@ export class ContentListCellResizeDirective implements OnInit, OnDestroy {
if (width < this.minimumWidth) { if (width < this.minimumWidth) {
width = this.minimumWidth; width = this.minimumWidth;
} }
this.tableFields!.updateSize(this.fieldName!, width, save); this.tableFields!.updateSize(this.fieldName!, width, save);
} }

4
frontend/src/app/shared/components/contents/content-list-field.component.ts

@ -7,7 +7,7 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnChanges } from '@angular/core'; import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnChanges } from '@angular/core';
import { FormGroup } from '@angular/forms'; import { FormGroup } from '@angular/forms';
import { ContentDto, FieldValue, getContentValue, LanguageDto, MetaFields, StatefulComponent, TableField, TableSettings } from '@app/shared/internal'; import { ContentDto, FieldValue, getContentValue, LanguageDto, META_FIELDS, StatefulComponent, TableField, TableSettings } from '@app/shared/internal';
interface State { interface State {
// The formatted value. // The formatted value.
@ -21,7 +21,7 @@ interface State {
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class ContentListFieldComponent extends StatefulComponent<State> implements OnChanges { export class ContentListFieldComponent extends StatefulComponent<State> implements OnChanges {
public readonly metaFields = MetaFields; public readonly metaFields = META_FIELDS;
@Input() @Input()
public field!: TableField; public field!: TableField;

10
frontend/src/app/shared/components/contents/content-list-header.component.ts

@ -6,7 +6,7 @@
*/ */
import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core'; import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core';
import { LanguageDto, MetaFields, Query, SortMode, TableField } from '@app/shared/internal'; import { LanguageDto, META_FIELDS, Query, SortMode, TableField } from '@app/shared/internal';
@Component({ @Component({
selector: 'sqx-content-list-header[field][language]', selector: 'sqx-content-list-header[field][language]',
@ -15,7 +15,7 @@ import { LanguageDto, MetaFields, Query, SortMode, TableField } from '@app/share
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class ContentListHeaderComponent implements OnChanges { export class ContentListHeaderComponent implements OnChanges {
public readonly metaFields = MetaFields; public readonly metaFields = META_FIELDS;
@Input() @Input()
public field!: TableField; public field!: TableField;
@ -35,9 +35,9 @@ export class ContentListHeaderComponent implements OnChanges {
public ngOnChanges() { public ngOnChanges() {
const { field, language } = this; const { field, language } = this;
if (field === MetaFields.created) { if (field === META_FIELDS.created) {
this.sortPath = 'created'; this.sortPath = 'created';
} else if (field === MetaFields.lastModified) { } else if (field === META_FIELDS.lastModified) {
this.sortPath = 'lastModified'; this.sortPath = 'lastModified';
} else if (field.rootField?.properties.isSortable !== true) { } else if (field.rootField?.properties.isSortable !== true) {
this.sortPath = undefined; this.sortPath = undefined;
@ -47,7 +47,7 @@ export class ContentListHeaderComponent implements OnChanges {
this.sortPath = `data.${field.name}.iv`; this.sortPath = `data.${field.name}.iv`;
} }
if (field === MetaFields.lastModified) { if (field === META_FIELDS.lastModified) {
this.sortDefault = 'descending'; this.sortDefault = 'descending';
} }
} }

2
frontend/src/app/shared/components/forms/markdown-editor.component.ts

@ -347,7 +347,7 @@ export class MarkdownEditorComponent extends StatefulControlComponent<State, str
.defined() .defined()
.join(', ') .join(', ')
|| 'content'; || 'content';
return `[${name}](${this.apiUrl.buildUrl(content._links['self'].href)})`; return `[${name}](${this.apiUrl.buildUrl(content._links['self'].href)})`;
} }

2
frontend/src/app/shared/components/forms/rich-editor.component.ts

@ -335,7 +335,7 @@ export class RichEditorComponent extends StatefulControlComponent<{}, string> im
.defined() .defined()
.join(', ') .join(', ')
|| 'content'; || 'content';
return `<a href="${this.apiUrl.buildUrl(content._links['self'].href)}" alt="${name}">${name}</a>`; return `<a href="${this.apiUrl.buildUrl(content._links['self'].href)}" alt="${name}">${name}</a>`;
} }

2
frontend/src/app/shared/components/pipes.ts

@ -72,7 +72,7 @@ export class UserNamePipe extends UserAsyncPipe implements OnDestroy, PipeTransf
} }
public transform(userId: string | undefined | null, placeholder = 'Me') { public transform(userId: string | undefined | null, placeholder = 'Me') {
return super.transformInternal(userId, (users, userId) => return super.transformInternal(userId, (users, userId) =>
users.getUser(userId, placeholder).pipe(map(u => u.displayName))); users.getUser(userId, placeholder).pipe(map(u => u.displayName)));
} }
} }

4
frontend/src/app/shared/components/references/content-selector-item.component.ts

@ -8,7 +8,7 @@
/* tslint:disable: component-selector */ /* tslint:disable: component-selector */
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { ContentDto, LanguageDto, MetaFields, SchemaDto } from '@app/shared/internal'; import { ContentDto, LanguageDto, META_FIELDS, SchemaDto } from '@app/shared/internal';
@Component({ @Component({
selector: '[sqxContentSelectorItem][language][schema]', selector: '[sqxContentSelectorItem][language][schema]',
@ -17,7 +17,7 @@ import { ContentDto, LanguageDto, MetaFields, SchemaDto } from '@app/shared/inte
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class ContentSelectorItemComponent { export class ContentSelectorItemComponent {
public readonly metaFields = MetaFields; public readonly metaFields = META_FIELDS;
@Output() @Output()
public selectedChange = new EventEmitter<boolean>(); public selectedChange = new EventEmitter<boolean>();

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

@ -8,7 +8,7 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { BehaviorSubject, of } from 'rxjs'; import { BehaviorSubject, of } from 'rxjs';
import { distinctUntilChanged, map, switchMap } from 'rxjs/operators'; import { distinctUntilChanged, map, switchMap } from 'rxjs/operators';
import { ApiUrlConfig, AppsState, ComponentContentsState, ContentDto, LanguageDto, MetaFields, Query, ResourceOwner, SchemaDto, SchemasService, SchemasState } from '@app/shared/internal'; import { ApiUrlConfig, AppsState, ComponentContentsState, ContentDto, LanguageDto, META_FIELDS, Query, ResourceOwner, SchemaDto, SchemasService, SchemasState } from '@app/shared/internal';
@Component({ @Component({
selector: 'sqx-content-selector[language][languages]', selector: 'sqx-content-selector[language][languages]',
@ -19,8 +19,8 @@ import { ApiUrlConfig, AppsState, ComponentContentsState, ContentDto, LanguageDt
], ],
}) })
export class ContentSelectorComponent extends ResourceOwner implements OnInit { export class ContentSelectorComponent extends ResourceOwner implements OnInit {
public readonly metaFields = MetaFields; public readonly metaFields = META_FIELDS;
@Output() @Output()
public select = new EventEmitter<ReadonlyArray<ContentDto>>(); public select = new EventEmitter<ReadonlyArray<ContentDto>>();

2
frontend/src/app/shared/components/references/reference-input.component.ts

@ -106,7 +106,7 @@ export class ReferenceInputComponent extends StatefulControlComponent<State, Rea
if (this.snapshot.isDisabled) { if (this.snapshot.isDisabled) {
return; return;
} }
const id = selectedContent?.id; const id = selectedContent?.id;
if (id) { if (id) {

6
frontend/src/app/shared/services/app-languages.service.spec.ts

@ -7,7 +7,7 @@
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { inject, TestBed } from '@angular/core/testing'; import { inject, TestBed } from '@angular/core/testing';
import { AnalyticsService, ApiUrlConfig, AppLanguageDto, AppLanguagesDto, AppLanguagesPayload, AppLanguagesService, Resource, ResourceLinks, Version } from '@app/shared/internal'; import { ApiUrlConfig, AppLanguageDto, AppLanguagesDto, AppLanguagesPayload, AppLanguagesService, Resource, ResourceLinks, Version } from '@app/shared/internal';
describe('AppLanguagesService', () => { describe('AppLanguagesService', () => {
const version = new Version('1'); const version = new Version('1');
@ -20,7 +20,6 @@ describe('AppLanguagesService', () => {
providers: [ providers: [
AppLanguagesService, AppLanguagesService,
{ provide: ApiUrlConfig, useValue: new ApiUrlConfig('http://service/p/') }, { provide: ApiUrlConfig, useValue: new ApiUrlConfig('http://service/p/') },
{ provide: AnalyticsService, useValue: new AnalyticsService() },
], ],
}); });
}); });
@ -155,9 +154,6 @@ describe('AppLanguagesService', () => {
export function createLanguages(...codes: ReadonlyArray<string>): AppLanguagesPayload { export function createLanguages(...codes: ReadonlyArray<string>): AppLanguagesPayload {
return { return {
items: codes.map((code, i) => createLanguage(code, codes, i)), items: codes.map((code, i) => createLanguage(code, codes, i)),
_links: {
create: { method: 'POST', href: '/languages' },
},
canCreate: true, canCreate: true,
}; };
} }

74
frontend/src/app/shared/services/app-languages.service.ts

@ -8,17 +8,15 @@
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators'; import { ApiUrlConfig, hasAnyLink, HTTP, mapVersioned, pretifyError, Resource, ResourceLinks, Version, Versioned } from '@app/framework';
import { AnalyticsService, ApiUrlConfig, hasAnyLink, HTTP, mapVersioned, pretifyError, Resource, ResourceLinks, Version, Versioned } from '@app/framework';
export class AppLanguageDto { export class AppLanguageDto {
public readonly _links: ResourceLinks; public readonly _links: ResourceLinks;
public readonly canUpdate: boolean;
public readonly canDelete: boolean; public readonly canDelete: boolean;
public readonly canUpdate: boolean;
constructor( constructor(links: ResourceLinks,
links: ResourceLinks,
public readonly iso2Code: string, public readonly iso2Code: string,
public readonly englishName: string, public readonly englishName: string,
public readonly isMaster: boolean, public readonly isMaster: boolean,
@ -27,29 +25,42 @@ export class AppLanguageDto {
) { ) {
this._links = links; this._links = links;
this.canUpdate = hasAnyLink(links, 'update');
this.canDelete = hasAnyLink(links, 'delete'); this.canDelete = hasAnyLink(links, 'delete');
this.canUpdate = hasAnyLink(links, 'update');
} }
} }
export type AppLanguagesDto = export type AppLanguagesDto = Versioned<AppLanguagesPayload>;
Versioned<AppLanguagesPayload>;
export type AppLanguagesPayload = Readonly<{
// The app languages.
items: ReadonlyArray<AppLanguageDto>;
// The if the user can create a new language.
canCreate?: boolean;
}>;
export type AppLanguagesPayload = export type AddAppLanguageDto = Readonly<{
Readonly<{ items: ReadonlyArray<AppLanguageDto>; canCreate: boolean } & Resource>; // The language code to add.
language: string;
}>;
export type AddAppLanguageDto = export type UpdateAppLanguageDto = Readonly<{
Readonly<{ language: string }>; // Indicates if the language is the master language.
isMaster?: boolean;
export type UpdateAppLanguageDto = // Indicates if the langauge is optional (cannot be master language).
Readonly<{ isMaster?: boolean; isOptional?: boolean; falback?: ReadonlyArray<string> }>; isOptional?: boolean;
// The fallback language codes.
falback?: ReadonlyArray<string>;
}>;
@Injectable() @Injectable()
export class AppLanguagesService { export class AppLanguagesService {
constructor( constructor(
private readonly http: HttpClient, private readonly http: HttpClient,
private readonly apiUrl: ApiUrlConfig, private readonly apiUrl: ApiUrlConfig,
private readonly analytics: AnalyticsService,
) { ) {
} }
@ -70,9 +81,6 @@ export class AppLanguagesService {
mapVersioned(({ body }) => { mapVersioned(({ body }) => {
return parseLanguages(body); return parseLanguages(body);
}), }),
tap(() => {
this.analytics.trackEvent('Language', 'Added', appName);
}),
pretifyError('i18n:languages.addFailed')); pretifyError('i18n:languages.addFailed'));
} }
@ -85,9 +93,6 @@ export class AppLanguagesService {
mapVersioned(({ body }) => { mapVersioned(({ body }) => {
return parseLanguages(body); return parseLanguages(body);
}), }),
tap(() => {
this.analytics.trackEvent('Language', 'Updated', appName);
}),
pretifyError('i18n:languages.updateFailed')); pretifyError('i18n:languages.updateFailed'));
} }
@ -100,23 +105,24 @@ export class AppLanguagesService {
mapVersioned(({ body }) => { mapVersioned(({ body }) => {
return parseLanguages(body); return parseLanguages(body);
}), }),
tap(() => {
this.analytics.trackEvent('Language', 'Deleted', appName);
}),
pretifyError('i18n:languages.deleteFailed')); pretifyError('i18n:languages.deleteFailed'));
} }
} }
function parseLanguages(response: { items: any[] } & Resource) { function parseLanguages(response: { items: any[] } & Resource): AppLanguagesPayload {
const items = response.items.map(item => const { items: list, _links } = response;
new AppLanguageDto(item._links, const items = list.map(parseLanguage);
item.iso2Code,
item.englishName, const canCreate = hasAnyLink(_links, 'create');
item.isMaster,
item.isOptional,
item.fallback || []));
const { _links } = response; return { items, canCreate };
}
return { items, _links, canCreate: hasAnyLink(_links, 'create') }; function parseLanguage(response: any) {
return new AppLanguageDto(response._links,
response.iso2Code,
response.englishName,
response.isMaster,
response.isOptional,
response.fallback || []);
} }

15
frontend/src/app/shared/services/apps.service.spec.ts

@ -7,7 +7,7 @@
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { inject, TestBed } from '@angular/core/testing'; import { inject, TestBed } from '@angular/core/testing';
import { AnalyticsService, ApiUrlConfig, AppDto, AppsService, DateTime, ErrorDto, Resource, ResourceLinks, Version } from '@app/shared/internal'; import { ApiUrlConfig, AppDto, AppsService, DateTime, ErrorDto, Resource, ResourceLinks, Version } from '@app/shared/internal';
import { AppSettingsDto, AssetScriptsDto, AssetScriptsPayload, EditorDto, PatternDto } from './apps.service'; import { AppSettingsDto, AssetScriptsDto, AssetScriptsPayload, EditorDto, PatternDto } from './apps.service';
describe('AppsService', () => { describe('AppsService', () => {
@ -21,7 +21,6 @@ describe('AppsService', () => {
providers: [ providers: [
AppsService, AppsService,
{ provide: ApiUrlConfig, useValue: new ApiUrlConfig('http://service/p/') }, { provide: ApiUrlConfig, useValue: new ApiUrlConfig('http://service/p/') },
{ provide: AnalyticsService, useValue: new AnalyticsService() },
], ],
}); });
}); });
@ -326,11 +325,11 @@ describe('AppsService', () => {
lastModifiedBy: `modifier${id}`, lastModifiedBy: `modifier${id}`,
version: key, version: key,
name: `app-name${key}`, name: `app-name${key}`,
label: `app-label${key}`,
description: `app-description${key}`,
permissions: ['Owner'],
canAccessApi: id % 2 === 0, canAccessApi: id % 2 === 0,
canAccessContent: id % 2 === 0, canAccessContent: id % 2 === 0,
description: `app-description${key}`,
label: `app-label${key}`,
permissions: ['Owner'],
roleName: `Role${id}`, roleName: `Role${id}`,
roleProperties: createProperties(id), roleProperties: createProperties(id),
_links: { _links: {
@ -418,12 +417,8 @@ export function createAppSettings(id: number, suffix = '') {
} }
export function createAssetScripts(id: number, suffix = ''): AssetScriptsPayload { export function createAssetScripts(id: number, suffix = ''): AssetScriptsPayload {
const key = `${id}${suffix}`;
return { return {
scripts: { scripts: { update: `${id}${suffix}` },
update: key,
},
_links: { _links: {
update: { method: 'PUT', href: `apps/${id}/assets/scripts` }, update: { method: 'PUT', href: `apps/${id}/assets/scripts` },
}, },

80
frontend/src/app/shared/services/apps.service.ts

@ -8,8 +8,8 @@
import { HttpClient, HttpErrorResponse, HttpEventType, HttpResponse } from '@angular/common/http'; import { HttpClient, HttpErrorResponse, HttpEventType, HttpResponse } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Observable, throwError } from 'rxjs'; import { Observable, throwError } from 'rxjs';
import { catchError, filter, map, tap } from 'rxjs/operators'; import { catchError, filter, map } from 'rxjs/operators';
import { AnalyticsService, ApiUrlConfig, DateTime, ErrorDto, getLinkUrl, hasAnyLink, HTTP, mapVersioned, pretifyError, Resource, ResourceLinks, StringHelper, Types, Version, Versioned } from '@app/framework'; import { ApiUrlConfig, DateTime, ErrorDto, getLinkUrl, hasAnyLink, HTTP, mapVersioned, pretifyError, Resource, ResourceLinks, StringHelper, Types, Version, Versioned } from '@app/framework';
export class AppDto { export class AppDto {
public readonly _links: ResourceLinks; public readonly _links: ResourceLinks;
@ -31,6 +31,7 @@ export class AppDto {
public readonly canUpdateGeneral: boolean; public readonly canUpdateGeneral: boolean;
public readonly canUpdateImage: boolean; public readonly canUpdateImage: boolean;
public readonly canUploadAssets: boolean; public readonly canUploadAssets: boolean;
public readonly image: string; public readonly image: string;
public readonly displayName: string; public readonly displayName: string;
@ -111,30 +112,47 @@ export class EditorDto {
} }
} }
export type AssetScriptsDto = export type AssetScripts = Readonly<{ [name: string]: string | null }>;
Versioned<AssetScriptsPayload>;
export type AssetScriptsDto = Versioned<AssetScriptsPayload>;
export type AssetScriptsPayload = Readonly<{
// The actual asset scripts.
scripts: AssetScripts;
// True, if the user has permissions to update the scripts.
canUpdate?: boolean;
}> & Resource;
export type AssetScriptsPayload = export type UpdateAppSettingsDto = Readonly<{
Readonly<{ scripts: AssetScripts; canUpdate: boolean } & Resource>; // The regex patterns for scehams.
patterns: ReadonlyArray<PatternDto>;
export type UpdateAppSettingsDto = // The registered editors for schemas.
Readonly<{ patterns: ReadonlyArray<PatternDto>; editors: ReadonlyArray<EditorDto>; hideScheduler?: boolean }>; editors: ReadonlyArray<EditorDto>;
export type AssetScripts = // True if the scheduler should be hidden.
Readonly<{ [name: string]: string | null }>; hideScheduler?: boolean;
}>;
export type CreateAppDto = export type CreateAppDto = Readonly<{
Readonly<{ name: string }>; // The new name of the app. Must be unique.
name: string;
}>;
export type UpdateAppDto = export type UpdateAppDto = Readonly<{
Readonly<{ label?: string; description?: string }>; // The label, which is like a display name.
label?: string;
// The description of the app.
description?: string;
}>;
@Injectable() @Injectable()
export class AppsService { export class AppsService {
constructor( constructor(
private readonly http: HttpClient, private readonly http: HttpClient,
private readonly apiUrl: ApiUrlConfig, private readonly apiUrl: ApiUrlConfig,
private readonly analytics: AnalyticsService,
) { ) {
} }
@ -169,9 +187,6 @@ export class AppsService {
map(body => { map(body => {
return parseApp(body); return parseApp(body);
}), }),
tap(() => {
this.analytics.trackEvent('App', 'Created', dto.name);
}),
pretifyError('i18n:apps.createFailed')); pretifyError('i18n:apps.createFailed'));
} }
@ -184,9 +199,6 @@ export class AppsService {
map(({ payload }) => { map(({ payload }) => {
return parseApp(payload.body); return parseApp(payload.body);
}), }),
tap(() => {
this.analytics.trackEvent('App', 'Updated', appName);
}),
pretifyError('i18n:apps.updateFailed')); pretifyError('i18n:apps.updateFailed'));
} }
@ -209,9 +221,6 @@ export class AppsService {
map(({ payload }) => { map(({ payload }) => {
return parseAppSettings(payload.body); return parseAppSettings(payload.body);
}), }),
tap(() => {
this.analytics.trackEvent('App', 'Updated', appName);
}),
pretifyError('i18n:apps.updateSettingsFailed')); pretifyError('i18n:apps.updateSettingsFailed'));
} }
@ -234,9 +243,6 @@ export class AppsService {
mapVersioned(({ body }) => { mapVersioned(({ body }) => {
return parseAssetScripts(body); return parseAssetScripts(body);
}), }),
tap(() => {
this.analytics.trackEvent('App', 'Updated', appName);
}),
pretifyError('i18n:apps.updateAssetScriptsFailed')); pretifyError('i18n:apps.updateAssetScriptsFailed'));
} }
@ -267,11 +273,6 @@ export class AppsService {
return throwError(() => error); return throwError(() => error);
} }
}), }),
tap(value => {
if (!Types.isNumber(value)) {
this.analytics.trackEvent('AppImage', 'Uploaded', appName);
}
}),
pretifyError('i18n:apps.uploadImageFailed')); pretifyError('i18n:apps.uploadImageFailed'));
} }
@ -284,9 +285,6 @@ export class AppsService {
map(({ payload }) => { map(({ payload }) => {
return parseApp(payload.body); return parseApp(payload.body);
}), }),
tap(() => {
this.analytics.trackEvent('AppImage', 'Removed', appName);
}),
pretifyError('i18n:apps.removeImageFailed')); pretifyError('i18n:apps.removeImageFailed'));
} }
@ -296,9 +294,6 @@ export class AppsService {
const url = this.apiUrl.buildUrl(link.href); const url = this.apiUrl.buildUrl(link.href);
return this.http.request(link.method, url).pipe( return this.http.request(link.method, url).pipe(
tap(() => {
this.analytics.trackEvent('App', 'Left', appName);
}),
pretifyError('i18n:apps.leaveFailed')); pretifyError('i18n:apps.leaveFailed'));
} }
@ -308,9 +303,6 @@ export class AppsService {
const url = this.apiUrl.buildUrl(link.href); const url = this.apiUrl.buildUrl(link.href);
return this.http.request(link.method, url).pipe( return this.http.request(link.method, url).pipe(
tap(() => {
this.analytics.trackEvent('App', 'Archived', appName);
}),
pretifyError('i18n:apps.archiveFailed')); pretifyError('i18n:apps.archiveFailed'));
} }
} }
@ -343,8 +335,10 @@ function parseAppSettings(response: any & Resource) {
new Version(response.version.toString())); new Version(response.version.toString()));
} }
function parseAssetScripts(response: any) { function parseAssetScripts(response: any): AssetScriptsPayload {
const { _links, ...scripts } = response; const { _links, ...scripts } = response;
return { scripts, _links, canUpdate: hasAnyLink(_links, 'update') }; const canUpdate = hasAnyLink(_links, 'update');
return { scripts, canUpdate, _links };
} }

24
frontend/src/app/shared/services/assets.service.spec.ts

@ -7,7 +7,7 @@
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { inject, TestBed } from '@angular/core/testing'; import { inject, TestBed } from '@angular/core/testing';
import { AnalyticsService, ApiUrlConfig, AssetCompletions, AssetDto, AssetFolderDto, AssetFoldersDto, AssetsDto, AssetsService, DateTime, ErrorDto, MathHelper, Resource, ResourceLinks, sanitize, Version } from '@app/shared/internal'; import { ApiUrlConfig, AssetCompletions, AssetDto, AssetFolderDto, AssetFoldersDto, AssetsDto, AssetsService, DateTime, ErrorDto, MathHelper, Resource, ResourceLinks, sanitize, Version } from '@app/shared/internal';
describe('AssetsService', () => { describe('AssetsService', () => {
const version = new Version('1'); const version = new Version('1');
@ -20,7 +20,6 @@ describe('AssetsService', () => {
providers: [ providers: [
AssetsService, AssetsService,
{ provide: ApiUrlConfig, useValue: new ApiUrlConfig('http://service/p/') }, { provide: ApiUrlConfig, useValue: new ApiUrlConfig('http://service/p/') },
{ provide: AnalyticsService, useValue: new AnalyticsService() },
], ],
}); });
}); });
@ -110,11 +109,15 @@ describe('AssetsService', () => {
], ],
}); });
expect(assets!).toEqual( expect(assets!).toEqual({
new AssetsDto(10, [ items: [
createAsset(12), createAsset(12),
createAsset(13), createAsset(13),
])); ],
total: 10,
canCreate: false,
canRenameTag: false,
});
})); }));
it('should make get request to get asset folders', it('should make get request to get asset folders',
@ -141,13 +144,16 @@ describe('AssetsService', () => {
], ],
}); });
expect(assetFolders!).toEqual( expect(assetFolders!).toEqual({
new AssetFoldersDto(10, [ items: [
createAssetFolder(22), createAssetFolder(22),
createAssetFolder(23), createAssetFolder(23),
], [ ],
path: [
createAssetFolder(44), createAssetFolder(44),
])); ],
canCreate: false,
});
})); }));
it('should make get request to get asset', it('should make get request to get asset',

192
frontend/src/app/shared/services/assets.service.ts

@ -8,8 +8,8 @@
import { HttpClient, HttpErrorResponse, HttpEventType, HttpResponse } from '@angular/common/http'; import { HttpClient, HttpErrorResponse, HttpEventType, HttpResponse } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Observable, throwError } from 'rxjs'; import { Observable, throwError } from 'rxjs';
import { catchError, filter, map, tap } from 'rxjs/operators'; import { catchError, filter, map } from 'rxjs/operators';
import { AnalyticsService, ApiUrlConfig, DateTime, ErrorDto, getLinkUrl, hasAnyLink, HTTP, Metadata, pretifyError, Resource, ResourceLinks, ResultSet, StringHelper, Types, Version, Versioned } from '@app/framework'; import { ApiUrlConfig, DateTime, ErrorDto, getLinkUrl, hasAnyLink, HTTP, Metadata, pretifyError, Resource, ResourceLinks, StringHelper, Types, Version, Versioned } from '@app/framework';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { Query, sanitize } from './query'; import { Query, sanitize } from './query';
@ -18,15 +18,7 @@ const SVG_PREVIEW_LIMIT = 10 * 1024;
const MIME_TIFF = 'image/tiff'; const MIME_TIFF = 'image/tiff';
const MIME_SVG = 'image/svg+xml'; const MIME_SVG = 'image/svg+xml';
export class AssetsDto extends ResultSet<AssetDto> { type AssetFolderScope = 'PathAndItems' | 'Path' | 'Items';
public get canCreate() {
return hasAnyLink(this._links, 'create');
}
public get canRenameTag() {
return hasAnyLink(this._links, 'tags/rename');
}
}
export class AssetDto { export class AssetDto {
public readonly _meta: Metadata = {}; public readonly _meta: Metadata = {};
@ -103,19 +95,6 @@ export class AssetDto {
} }
} }
export class AssetFoldersDto extends ResultSet<AssetFolderDto> {
constructor(total: number, items: ReadonlyArray<AssetFolderDto>,
public readonly path: ReadonlyArray<AssetFolderDto>,
links?: ResourceLinks,
) {
super(total, items, links);
}
public get canCreate() {
return hasAnyLink(this._links, 'create');
}
}
export class AssetFolderDto { export class AssetFolderDto {
public readonly _links: ResourceLinks; public readonly _links: ResourceLinks;
@ -137,47 +116,119 @@ export class AssetFolderDto {
} }
} }
type Tags = readonly string[]; export type AssetsDto = Readonly<{
// The list of assets.
items: ReadonlyArray<AssetDto>;
type AssetFolderScope = 'PathAndItems' | 'Path' | 'Items'; // The total number of assets.
type AssetMetadata = { [key: string]: any }; total: number;
// True, if the user has permissions to create an asset.
canCreate?: boolean;
// True, if the user has permissions to rename a tag.
canRenameTag?: boolean;
}>;
export type AssetFoldersDto = Readonly<{
// The list of asset folders.
items: ReadonlyArray<AssetFolderDto>;
// The path to the asset folders.
path: ReadonlyArray<AssetFolderDto>;
// True, if the user has permissions to create an asset folder.
canCreate?: boolean;
}>;
export type AssetCompletions = ReadonlyArray<{
// The autocompletion path.
path: string;
// The description of the autocompletion field.
description: string;
// The type of the autocompletion field.
type: string;
}>;
export type AssetCompletions = export type AnnotateAssetDto = Readonly<{
ReadonlyArray<{ path: string; description: string; type: string }>; // The optional file name.
fileName?: string;
export type AnnotateAssetDto = // The optional flag, if an asset is protected.
Readonly<{ fileName?: string; isProtected?: boolean; slug?: string; tags?: Tags; metadata?: AssetMetadata }>; isProtected?: boolean;
export type CreateAssetFolderDto = // The optiona slug.
Readonly<{ folderName: string } & MoveAssetItemDto>; slug?: string;
export type RenameAssetFolderDto = // The optional tags.
Readonly<{ folderName: string }>; tags?: ReadonlyArray<string>;
export type RenameAssetTagDto = // The optional metadata.
Readonly<{ tagName: string }>; metadata?: { [key: string]: any };
}>;
export type MoveAssetItemDto = export type CreateAssetFolderDto = Readonly<{
Readonly<{ parentId?: string }>; // The name of the folder.
folderName: string;
} & MoveAssetItemDto>;
export type AssetsQuery = export type RenameAssetFolderDto = Readonly<{
Readonly<{ noTotal?: boolean; noSlowTotal?: boolean }>; // The name of the folder.
folderName: string;
}>;
export type AssetsByRef = export type RenameAssetTagDto = Readonly<{
Readonly<{ ref: string }>; // The name of the tag.
tagName: string;
}>;
export type AssetsByIds = export type MoveAssetItemDto = Readonly<{
Readonly<{ ids: ReadonlyArray<string> }>; // The new ID to the asset folder.
parentId?: string;
}>;
export type AssetsByQuery = export type AssetsQuery = Readonly<{
Readonly<{ query?: Query; skip?: number; tags?: Tags; take?: number; parentId?: string }>; // True, to not return the total number of items.
noTotal?: boolean;
// True, to not return the total number of items, if the query would be slow.
noSlowTotal?: boolean;
}>;
export type AssetsByRef = Readonly<{
// The reference.
ref: string;
}>;
export type AssetsByIds = Readonly<{
// The IDs of the assets.
ids: ReadonlyArray<string>;
}>;
export type AssetsByQuery = Readonly<{
// The JSON query.
query?: Query;
// The number of items to skip.
skip?: number;
// The number of items to take.
take?: number;
// The tags to filter.
tags?: ReadonlyArray<string>;
// The ID of the asset folder.
parentId?: string;
}>;
@Injectable() @Injectable()
export class AssetsService { export class AssetsService {
constructor( constructor(
private readonly http: HttpClient, private readonly http: HttpClient,
private readonly apiUrl: ApiUrlConfig, private readonly apiUrl: ApiUrlConfig,
private readonly analytics: AnalyticsService,
) { ) {
} }
@ -256,11 +307,6 @@ export class AssetsService {
return throwError(() => error); return throwError(() => error);
} }
}), }),
tap(value => {
if (!Types.isNumber(value)) {
this.analytics.trackEvent('Asset', 'Uploaded', appName);
}
}),
pretifyError('i18n:assets.uploadFailed')); pretifyError('i18n:assets.uploadFailed'));
} }
@ -291,11 +337,6 @@ export class AssetsService {
return throwError(() => error); return throwError(() => error);
} }
}), }),
tap(value => {
if (!Types.isNumber(value)) {
this.analytics.trackEvent('Asset', 'Replaced', appName);
}
}),
pretifyError('i18n:assets.replaceFailed')); pretifyError('i18n:assets.replaceFailed'));
} }
@ -306,9 +347,6 @@ export class AssetsService {
map(({ payload }) => { map(({ payload }) => {
return parseAssetFolder(payload.body); return parseAssetFolder(payload.body);
}), }),
tap(() => {
this.analytics.trackEvent('AssetFolder', 'Updated', appName);
}),
pretifyError('i18n:assets.createFolderFailed')); pretifyError('i18n:assets.createFolderFailed'));
} }
@ -321,9 +359,6 @@ export class AssetsService {
map(({ payload }) => { map(({ payload }) => {
return parseAsset(payload.body); return parseAsset(payload.body);
}), }),
tap(() => {
this.analytics.trackEvent('Asset', 'Updated', appName);
}),
pretifyError('i18n:assets.updateFailed')); pretifyError('i18n:assets.updateFailed'));
} }
@ -336,9 +371,6 @@ export class AssetsService {
map(({ payload }) => { map(({ payload }) => {
return parseAssetFolder(payload.body); return parseAssetFolder(payload.body);
}), }),
tap(() => {
this.analytics.trackEvent('AssetFolder', 'Updated', appName);
}),
pretifyError('i18n:assets.updateFolderFailed')); pretifyError('i18n:assets.updateFolderFailed'));
} }
@ -348,9 +380,6 @@ export class AssetsService {
const url = this.apiUrl.buildUrl(link.href); const url = this.apiUrl.buildUrl(link.href);
return HTTP.requestVersioned(this.http, link.method, url, version, dto).pipe( return HTTP.requestVersioned(this.http, link.method, url, version, dto).pipe(
tap(() => {
this.analytics.trackEvent('Asset', 'Moved', appName);
}),
pretifyError('i18n:assets.moveFailed')); pretifyError('i18n:assets.moveFailed'));
} }
@ -360,9 +389,6 @@ export class AssetsService {
const url = `${this.apiUrl.buildUrl(link.href)}?checkReferrers=${checkReferrers}`; const url = `${this.apiUrl.buildUrl(link.href)}?checkReferrers=${checkReferrers}`;
return HTTP.requestVersioned(this.http, link.method, url, version).pipe( return HTTP.requestVersioned(this.http, link.method, url, version).pipe(
tap(() => {
this.analytics.trackEvent('Asset', 'Deleted', appName);
}),
pretifyError('i18n:assets.deleteFailed')); pretifyError('i18n:assets.deleteFailed'));
} }
@ -452,10 +478,14 @@ function buildQuery(q?: AssetsQuery & AssetsByQuery & AssetsByIds & AssetsByRef)
return body; return body;
} }
function parseAssets(response: { items: any[]; total: number } & Resource) { function parseAssets(response: { items: any[]; total: number } & Resource): AssetsDto {
const items = response.items.map(parseAsset); const { items: list, total, _links } = response;
const items = list.map(parseAsset);
return new AssetsDto(response.total, items, response._links); const canCreate = hasAnyLink(_links, 'create');
const canRenameTag = hasAnyLink(_links, 'tags/rename');
return { items, total, canCreate, canRenameTag };
} }
function parseAsset(response: any) { function parseAsset(response: any) {
@ -479,11 +509,13 @@ function parseAsset(response: any) {
response.tags || []); response.tags || []);
} }
function parseAssetFolders(response: { items: any[]; path: any[]; total: number } & Resource) { function parseAssetFolders(response: { items: any[]; path: any[]; total: number } & Resource): AssetFoldersDto {
const assetFolders = response.items.map(parseAssetFolder); const { items: list, _links } = response;
const assetPath = response.path.map(parseAssetFolder); const items = list.map(parseAssetFolder);
const canCreate = hasAnyLink(_links, 'create');
return new AssetFoldersDto(response.total, assetFolders, assetPath, response._links); return { items, canCreate, path: response.path.map(parseAssetFolder) };
} }
function parseAssetFolder(response: any) { function parseAssetFolder(response: any) {

11
frontend/src/app/shared/services/backups.service.spec.ts

@ -7,7 +7,7 @@
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { inject, TestBed } from '@angular/core/testing'; import { inject, TestBed } from '@angular/core/testing';
import { AnalyticsService, ApiUrlConfig, BackupDto, BackupsDto, BackupsService, DateTime, Resource, ResourceLinks, RestoreDto } from '@app/shared/internal'; import { ApiUrlConfig, BackupDto, BackupsDto, BackupsService, DateTime, Resource, ResourceLinks, RestoreDto } from '@app/shared/internal';
describe('BackupsService', () => { describe('BackupsService', () => {
beforeEach(() => { beforeEach(() => {
@ -18,7 +18,6 @@ describe('BackupsService', () => {
providers: [ providers: [
BackupsService, BackupsService,
{ provide: ApiUrlConfig, useValue: new ApiUrlConfig('http://service/p/') }, { provide: ApiUrlConfig, useValue: new ApiUrlConfig('http://service/p/') },
{ provide: AnalyticsService, useValue: new AnalyticsService() },
], ],
}); });
}); });
@ -47,11 +46,13 @@ describe('BackupsService', () => {
], ],
}); });
expect(backups!).toEqual( expect(backups!).toEqual({
new BackupsDto(2, [ items: [
createBackup(12), createBackup(12),
createBackup(13), createBackup(13),
], {})); ],
canCreate: false,
});
})); }));
it('should make get request to get restore', it('should make get request to get restore',

49
frontend/src/app/shared/services/backups.service.ts

@ -8,14 +8,8 @@
import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Observable, of, throwError } from 'rxjs'; import { Observable, of, throwError } from 'rxjs';
import { catchError, map, tap } from 'rxjs/operators'; import { catchError, map } from 'rxjs/operators';
import { AnalyticsService, ApiUrlConfig, DateTime, hasAnyLink, pretifyError, Resource, ResourceLinks, ResultSet, Types } from '@app/framework'; import { ApiUrlConfig, DateTime, hasAnyLink, pretifyError, Resource, ResourceLinks, Types } from '@app/framework';
export class BackupsDto extends ResultSet<BackupDto> {
public get canCreate() {
return hasAnyLink(this._links, 'create');
}
}
export class BackupDto { export class BackupDto {
public readonly _links: ResourceLinks; public readonly _links: ResourceLinks;
@ -29,8 +23,7 @@ export class BackupDto {
return this.status === 'Failed'; return this.status === 'Failed';
} }
constructor( constructor(links: ResourceLinks,
links: ResourceLinks,
public readonly id: string, public readonly id: string,
public readonly started: DateTime, public readonly started: DateTime,
public readonly stopped: DateTime | null, public readonly stopped: DateTime | null,
@ -58,15 +51,27 @@ export class RestoreDto {
} }
} }
export type StartRestoreDto = export type BackupsDto = Readonly<{
Readonly<{ url: string; newAppName?: string }>; // The list of backups.
items: ReadonlyArray<BackupDto>;
// True, if the user has permissions to create a backup.
canCreate?: boolean;
}>;
export type StartRestoreDto = Readonly<{
// The url of the backup file.
url: string;
// The optional app name tro use.
newAppName?: string;
}>;
@Injectable() @Injectable()
export class BackupsService { export class BackupsService {
constructor( constructor(
private readonly http: HttpClient, private readonly http: HttpClient,
private readonly apiUrl: ApiUrlConfig, private readonly apiUrl: ApiUrlConfig,
private readonly analytics: AnalyticsService,
) { ) {
} }
@ -103,9 +108,6 @@ export class BackupsService {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/backups`); const url = this.apiUrl.buildUrl(`api/apps/${appName}/backups`);
return this.http.post(url, {}).pipe( return this.http.post(url, {}).pipe(
tap(() => {
this.analytics.trackEvent('Backup', 'Started', appName);
}),
pretifyError('i18n:backups.startFailed')); pretifyError('i18n:backups.startFailed'));
} }
@ -113,9 +115,6 @@ export class BackupsService {
const url = this.apiUrl.buildUrl('api/apps/restore'); const url = this.apiUrl.buildUrl('api/apps/restore');
return this.http.post(url, dto).pipe( return this.http.post(url, dto).pipe(
tap(() => {
this.analytics.trackEvent('Restore', 'Started');
}),
pretifyError('i18n:backups.restoreFailed')); pretifyError('i18n:backups.restoreFailed'));
} }
@ -125,17 +124,17 @@ export class BackupsService {
const url = this.apiUrl.buildUrl(link.href); const url = this.apiUrl.buildUrl(link.href);
return this.http.request(link.method, url).pipe( return this.http.request(link.method, url).pipe(
tap(() => {
this.analytics.trackEvent('Backup', 'Deleted', appName);
}),
pretifyError('i18n:backups.deleteFailed')); pretifyError('i18n:backups.deleteFailed'));
} }
} }
function parseBackups(response: { items: any[] } & Resource) { function parseBackups(response: { items: any[] } & Resource): BackupsDto {
const items = response.items.map(parseBackup); const { items: list, _links } = response;
const items = list.map(parseBackup);
const canCreate = hasAnyLink(_links, 'create');
return new BackupsDto(items.length, items, response._links); return { items, canCreate };
} }
function parseRestore(response: any) { function parseRestore(response: any) {

6
frontend/src/app/shared/services/clients.service.spec.ts

@ -7,7 +7,7 @@
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { inject, TestBed } from '@angular/core/testing'; import { inject, TestBed } from '@angular/core/testing';
import { AccessTokenDto, AnalyticsService, ApiUrlConfig, ClientDto, ClientsDto, ClientsPayload, ClientsService, Resource, ResourceLinks, Version } from '@app/shared/internal'; import { AccessTokenDto, ApiUrlConfig, ClientDto, ClientsDto, ClientsPayload, ClientsService, Resource, ResourceLinks, Version } from '@app/shared/internal';
describe('ClientsService', () => { describe('ClientsService', () => {
const version = new Version('1'); const version = new Version('1');
@ -20,7 +20,6 @@ describe('ClientsService', () => {
providers: [ providers: [
ClientsService, ClientsService,
{ provide: ApiUrlConfig, useValue: new ApiUrlConfig('http://service/p/') }, { provide: ApiUrlConfig, useValue: new ApiUrlConfig('http://service/p/') },
{ provide: AnalyticsService, useValue: new AnalyticsService() },
], ],
}); });
}); });
@ -177,9 +176,6 @@ describe('ClientsService', () => {
export function createClients(...ids: ReadonlyArray<number>): ClientsPayload { export function createClients(...ids: ReadonlyArray<number>): ClientsPayload {
return { return {
items: ids.map(createClient), items: ids.map(createClient),
_links: {
create: { method: 'POST', href: '/clients' },
},
canCreate: true, canCreate: true,
}; };
} }

84
frontend/src/app/shared/services/clients.service.ts

@ -8,17 +8,16 @@
import { HttpClient, HttpHeaders } from '@angular/common/http'; import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { map, tap } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { AnalyticsService, ApiUrlConfig, hasAnyLink, HTTP, mapVersioned, pretifyError, Resource, ResourceLinks, Version, Versioned } from '@app/framework'; import { ApiUrlConfig, hasAnyLink, HTTP, mapVersioned, pretifyError, Resource, ResourceLinks, Version, Versioned } from '@app/framework';
export class ClientDto { export class ClientDto {
public readonly _links: ResourceLinks; public readonly _links: ResourceLinks;
public readonly canUpdate: boolean;
public readonly canRevoke: boolean; public readonly canRevoke: boolean;
public readonly canUpdate: boolean;
constructor( constructor(links: ResourceLinks,
links: ResourceLinks,
public readonly id: string, public readonly id: string,
public readonly name: string, public readonly name: string,
public readonly secret: string, public readonly secret: string,
@ -29,8 +28,8 @@ export class ClientDto {
) { ) {
this._links = links; this._links = links;
this.canUpdate = hasAnyLink(links, 'update');
this.canRevoke = hasAnyLink(links, 'delete'); this.canRevoke = hasAnyLink(links, 'delete');
this.canUpdate = hasAnyLink(links, 'update');
} }
} }
@ -42,24 +41,40 @@ export class AccessTokenDto {
} }
} }
export type ClientsDto = export type ClientsDto = Versioned<ClientsPayload>;
Versioned<ClientsPayload>;
export type ClientsPayload = Readonly<{
// The list of clients.
items: ReadonlyArray<ClientDto>;
// True if the user has permissions to create a client.
canCreate?: boolean;
}>;
export type CreateClientDto = Readonly<{
// The new client ID.
id: string;
}>;
export type ClientsPayload = export type UpdateClientDto = Readonly<{
Readonly<{ items: ReadonlyArray<ClientDto>; canCreate: boolean } & Resource>; // The optional client name.
name?: string;
export type CreateClientDto = // The role for the client to define the permissions.
Readonly<{ id: string }>; role?: string;
export type UpdateClientDto = // True if the client can be used for anonymous access.
Readonly<{ name?: string; role?: string; allowAnonymous?: boolean; apiCallsLimit?: number }>; allowAnonymous?: boolean;
// The allowed api calls.
apiCallsLimit?: number;
}>;
@Injectable() @Injectable()
export class ClientsService { export class ClientsService {
constructor( constructor(
private readonly http: HttpClient, private readonly http: HttpClient,
private readonly apiUrl: ApiUrlConfig, private readonly apiUrl: ApiUrlConfig,
private readonly analytics: AnalyticsService,
) { ) {
} }
@ -80,9 +95,6 @@ export class ClientsService {
mapVersioned(({ body }) => { mapVersioned(({ body }) => {
return parseClients(body); return parseClients(body);
}), }),
tap(() => {
this.analytics.trackEvent('Client', 'Created', appName);
}),
pretifyError('i18n:clients.addFailed')); pretifyError('i18n:clients.addFailed'));
} }
@ -95,9 +107,6 @@ export class ClientsService {
mapVersioned(({ body }) => { mapVersioned(({ body }) => {
return parseClients(body); return parseClients(body);
}), }),
tap(() => {
this.analytics.trackEvent('Client', 'Updated', appName);
}),
pretifyError('i18n:clients.revokeFailed')); pretifyError('i18n:clients.revokeFailed'));
} }
@ -110,9 +119,6 @@ export class ClientsService {
mapVersioned(({ body }) => { mapVersioned(({ body }) => {
return parseClients(body); return parseClients(body);
}), }),
tap(() => {
this.analytics.trackEvent('Client', 'Deleted', appName);
}),
pretifyError('i18n:clients.revokeFailed')); pretifyError('i18n:clients.revokeFailed'));
} }
@ -136,17 +142,21 @@ export class ClientsService {
} }
function parseClients(response: { items: any[] } & Resource): ClientsPayload { function parseClients(response: { items: any[] } & Resource): ClientsPayload {
const items = response.items.map(item => const { items: list, _links } = response;
new ClientDto(item._links, const items = list.map(parseClient);
item.id,
item.name || item.id, const canCreate = hasAnyLink(_links, 'create');
item.secret,
item.role, return { items, canCreate };
item.apiCallsLimit, }
item.apiTrafficLimit,
item.allowAnonymous)); function parseClient(response: any) {
return new ClientDto(response._links,
const { _links } = response; response.id,
response.name || response.id,
return { items, _links, canCreate: hasAnyLink(_links, 'create') }; response.secret,
response.role,
response.apiCallsLimit,
response.apiTrafficLimit,
response.allowAnonymous);
} }

9
frontend/src/app/shared/services/comments.service.ts

@ -34,8 +34,13 @@ export class CommentDto extends Model<CommentDto> {
} }
} }
export type UpsertCommentDto = export type UpsertCommentDto = Readonly<{
Readonly<{ text: string; url?: string }>; // The text to comment.
text: string;
// The url to the comment.
url?: string;
}>;
@Injectable() @Injectable()
export class CommentsService { export class CommentsService {

16
frontend/src/app/shared/services/contents.service.spec.ts

@ -8,7 +8,7 @@
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { inject, TestBed } from '@angular/core/testing'; import { inject, TestBed } from '@angular/core/testing';
import { ErrorDto } from '@app/framework'; import { ErrorDto } from '@app/framework';
import { AnalyticsService, ApiUrlConfig, ContentDto, ContentsDto, ContentsService, DateTime, Resource, ResourceLinks, ScheduleDto, Version, Versioned } from '@app/shared/internal'; import { ApiUrlConfig, ContentDto, ContentsDto, ContentsService, DateTime, Resource, ResourceLinks, ScheduleDto, Version, Versioned } from '@app/shared/internal';
import { BulkResultDto, BulkUpdateDto } from './contents.service'; import { BulkResultDto, BulkUpdateDto } from './contents.service';
import { sanitize } from './query'; import { sanitize } from './query';
@ -23,7 +23,6 @@ describe('ContentsService', () => {
providers: [ providers: [
ContentsService, ContentsService,
{ provide: ApiUrlConfig, useValue: new ApiUrlConfig('http://service/p/') }, { provide: ApiUrlConfig, useValue: new ApiUrlConfig('http://service/p/') },
{ provide: AnalyticsService, useValue: new AnalyticsService() },
], ],
}); });
}); });
@ -63,11 +62,18 @@ describe('ContentsService', () => {
}], }],
}); });
expect(contents!).toEqual( expect(contents!).toEqual({
new ContentsDto([{ status: 'Draft', color: 'Gray' }], 10, [ items: [
createContent(12), createContent(12),
createContent(13), createContent(13),
])); ],
total: 10,
statuses: [
{ status: 'Draft', color: 'Gray' },
],
canCreate: false,
canCreateAndPublish: false,
});
})); }));
it('should make post request to get contents with odata filter', it('should make post request to get contents with odata filter',

171
frontend/src/app/shared/services/contents.service.ts

@ -8,8 +8,8 @@
import { HttpClient, HttpHeaders } from '@angular/common/http'; import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { map, tap } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { AnalyticsService, ApiUrlConfig, DateTime, ErrorDto, hasAnyLink, HTTP, mapVersioned, pretifyError, Resource, ResourceLinks, ResultSet, Version, Versioned } from '@app/framework'; import { ApiUrlConfig, DateTime, ErrorDto, hasAnyLink, HTTP, mapVersioned, pretifyError, Resource, ResourceLinks, Version, Versioned } from '@app/framework';
import { StatusInfo } from './../state/contents.state'; import { StatusInfo } from './../state/contents.state';
import { Query, sanitize } from './query'; import { Query, sanitize } from './query';
import { parseField, RootFieldDto } from './schemas.service'; import { parseField, RootFieldDto } from './schemas.service';
@ -24,25 +24,6 @@ export class ScheduleDto {
} }
} }
export class ContentsDto extends ResultSet<ContentDto> {
constructor(
public readonly statuses: ReadonlyArray<StatusInfo>,
total: number,
items: ReadonlyArray<ContentDto>,
links?: ResourceLinks,
) {
super(total, items, links);
}
public get canCreate() {
return hasAnyLink(this._links, 'create');
}
public get canCreateAndPublish() {
return hasAnyLink(this._links, 'create/publish');
}
}
export class ContentDto { export class ContentDto {
public readonly _links: ResourceLinks; public readonly _links: ResourceLinks;
@ -104,47 +85,114 @@ export class BulkResultDto {
} }
} }
export type BulkUpdateType = 'Upsert' | 'ChangeStatus' | 'Delete' | 'Validate'; export type ContentsDto = Readonly<{
// The list of content items.
items: ReadonlyArray<ContentDto>;
// The total number of content items.
total: number;
// The statuses.
statuses: ReadonlyArray<StatusInfo>;
// True, if the user has permissions to create a content item.
canCreate?: boolean;
// True, if the user has permissions to create and publish a content item.
canCreateAndPublish?: boolean;
}>;
export type ContentReferencesValue = Readonly<{
// The references by partition.
[partition: string]: string;
}> | string;
export type ContentReferences = Readonly<{
// The reference values by field name.
[fieldName: string ]: ContentFieldData<ContentReferencesValue>;
}>;
export type ContentFieldData<T = any> = Readonly<{
// The data by partition.
[partition: string]: T;
}>;
export type ContentReferencesValue = export type ContentData = Readonly<{
Readonly<{ [partition: string]: string }> | string; // The content data by field name.
[fieldName: string ]: ContentFieldData;
}>;
export type ContentReferences = export type BulkStatusDto = Readonly<{
Readonly<{ [fieldName: string ]: ContentFieldData<ContentReferencesValue> }>; }>;
export type ContentFieldData<T = any> = export type BulkUpdateDto = Readonly<{
Readonly<{ [partition: string]: T }>; // The list of bulk update jobs.
jobs: ReadonlyArray<BulkUpdateJobDto>;
export type ContentData = // True, if scripts should not be executed.
Readonly<{ [fieldName: string ]: ContentFieldData }>; doNotScript?: boolean;
export type BulkStatusDto = // True, if referrers should be checked.
Readonly<{ status?: string; dueTime?: string | null }>; checkReferrers?: boolean;
}>;
export type BulkUpdateDto = export type BulkUpdateJobDto = Readonly<{
Readonly<{ jobs: ReadonlyArray<BulkUpdateJobDto>; doNotScript?: boolean; checkReferrers?: boolean }>; // The ID of the content to update.
id: string;
export type BulkUpdateJobDto = // The type of the bulk update job.
Readonly<{ id: string; type: BulkUpdateType; schema?: string; expectedVersion?: number }> & BulkStatusDto; type: 'Upsert' | 'ChangeStatus' | 'Delete' | 'Validate';
export type ContentsQuery = // The schema of the content item.
Readonly<{ noTotal?: boolean; noSlowTotal?: boolean }>; schema?: string;
export type ContentsByIds = // The new status.
Readonly<{ ids: ReadonlyArray<string> }> & ContentsQuery; status?: string;
export type ContentsBySchedule = // The due time of the new status.
Readonly<{ scheduledFrom: string | null; scheduledTo: string | null }> & ContentsQuery; dueTime?: string | null;
type ContentsByQuery = // The expected version of the content.
Readonly<{ query?: Query; skip?: number; take?: number }> & ContentsQuery; expectedVersion?: number;
}>;
export type ContentsQuery = Readonly<{
// True, to not return the total number of items.
noTotal?: boolean;
// True, to not return the total number of items, if the query would be slow.
noSlowTotal?: boolean;
}>;
export type ContentsByIds = Readonly<{
// The Ids of the contents to query.
ids: ReadonlyArray<string>;
}> & ContentsQuery;
export type ContentsBySchedule = Readonly<{
// The start of the time frame for scheduled content items.
scheduledFrom: string | null;
// The end of the time frame for scheduled content items.
scheduledTo: string | null;
}> & ContentsQuery;
export type ContentsByQuery = Readonly<{
// The JSON query.
query?: Query;
// The number of items to skip.
skip?: number;
// The number of items to take.
take?: number;
}> & ContentsQuery;
@Injectable() @Injectable()
export class ContentsService { export class ContentsService {
constructor( constructor(
private readonly http: HttpClient, private readonly http: HttpClient,
private readonly apiUrl: ApiUrlConfig, private readonly apiUrl: ApiUrlConfig,
private readonly analytics: AnalyticsService,
) { ) {
} }
@ -230,9 +278,6 @@ export class ContentsService {
map(({ payload }) => { map(({ payload }) => {
return parseContent(payload.body); return parseContent(payload.body);
}), }),
tap(() => {
this.analytics.trackEvent('Content', 'Created', appName);
}),
pretifyError('i18n:contents.createFailed')); pretifyError('i18n:contents.createFailed'));
} }
@ -245,9 +290,6 @@ export class ContentsService {
map(({ payload }) => { map(({ payload }) => {
return parseContent(payload.body); return parseContent(payload.body);
}), }),
tap(() => {
this.analytics.trackEvent('Content', 'Updated', appName);
}),
pretifyError('i18n:contents.updateFailed')); pretifyError('i18n:contents.updateFailed'));
} }
@ -260,9 +302,6 @@ export class ContentsService {
map(({ payload }) => { map(({ payload }) => {
return parseContent(payload.body); return parseContent(payload.body);
}), }),
tap(() => {
this.analytics.trackEvent('Content', 'Updated', appName);
}),
pretifyError('i18n:contents.updateFailed')); pretifyError('i18n:contents.updateFailed'));
} }
@ -275,9 +314,6 @@ export class ContentsService {
map(({ payload }) => { map(({ payload }) => {
return parseContent(payload.body); return parseContent(payload.body);
}), }),
tap(() => {
this.analytics.trackEvent('Content', 'VersioNCreated', appName);
}),
pretifyError('i18n:contents.loadVersionFailed')); pretifyError('i18n:contents.loadVersionFailed'));
} }
@ -290,9 +326,6 @@ export class ContentsService {
map(({ payload }) => { map(({ payload }) => {
return parseContent(payload.body); return parseContent(payload.body);
}), }),
tap(() => {
this.analytics.trackEvent('Content', 'Cancelled', appName);
}),
pretifyError('i18n:contents.updateFailed')); pretifyError('i18n:contents.updateFailed'));
} }
@ -305,9 +338,6 @@ export class ContentsService {
map(({ payload }) => { map(({ payload }) => {
return parseContent(payload.body); return parseContent(payload.body);
}), }),
tap(() => {
this.analytics.trackEvent('Content', 'VersionDeleted', appName);
}),
pretifyError('i18n:contents.deleteVersionFailed')); pretifyError('i18n:contents.deleteVersionFailed'));
} }
@ -318,9 +348,6 @@ export class ContentsService {
map(body => { map(body => {
return body.map(x => new BulkResultDto(x.contentId, parseError(x.error))); return body.map(x => new BulkResultDto(x.contentId, parseError(x.error)));
}), }),
tap(() => {
this.analytics.trackEvent('Content', 'Deleted', appName);
}),
pretifyError('i18n:contents.bulkFailed')); pretifyError('i18n:contents.bulkFailed'));
} }
} }
@ -377,13 +404,17 @@ function buildQuery(q?: ContentsByQuery) {
return body; return body;
} }
function parseContents(response: { items: any[]; total: number; statuses: any } & Resource) { function parseContents(response: { items: any[]; total: number; statuses: any } & Resource): ContentsDto {
const items = response.items.map(parseContent); const { items: list, total, statuses, _links } = response;
const items = list.map(parseContent);
const canCreate = hasAnyLink(_links, 'create');
const canCreateAndPublish = hasAnyLink(_links, 'create/publish');
return new ContentsDto(response.statuses, response.total, items, response._links); return { items, total, statuses, canCreate, canCreateAndPublish };
} }
function parseContent(response: any & Resource) { function parseContent(response: any) {
return new ContentDto(response._links, return new ContentDto(response._links,
response.id, response.id,
DateTime.parseISO(response.created), response.createdBy, DateTime.parseISO(response.created), response.createdBy,

12
frontend/src/app/shared/services/contributors.service.spec.ts

@ -7,7 +7,7 @@
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { inject, TestBed } from '@angular/core/testing'; import { inject, TestBed } from '@angular/core/testing';
import { AnalyticsService, ApiUrlConfig, ContributorDto, ContributorsDto, ContributorsPayload, ContributorsService, Resource, ResourceLinks, Version } from '@app/shared/internal'; import { ApiUrlConfig, ContributorDto, ContributorsDto, ContributorsPayload, ContributorsService, Resource, ResourceLinks, Version } from '@app/shared/internal';
describe('ContributorsService', () => { describe('ContributorsService', () => {
const version = new Version('1'); const version = new Version('1');
@ -20,7 +20,6 @@ describe('ContributorsService', () => {
providers: [ providers: [
ContributorsService, ContributorsService,
{ provide: ApiUrlConfig, useValue: new ApiUrlConfig('http://service/p/') }, { provide: ApiUrlConfig, useValue: new ApiUrlConfig('http://service/p/') },
{ provide: AnalyticsService, useValue: new AnalyticsService() },
], ],
}); });
}); });
@ -127,14 +126,9 @@ describe('ContributorsService', () => {
export function createContributors(...ids: ReadonlyArray<number>): ContributorsPayload { export function createContributors(...ids: ReadonlyArray<number>): ContributorsPayload {
return { return {
items: ids.map(createContributor),
maxContributors: ids.length * 13, maxContributors: ids.length * 13,
_links: { items: ids.map(createContributor),
create: { method: 'POST', href: '/contributors' }, isInvited: false,
},
_meta: {
isInvited: 'true',
},
canCreate: true, canCreate: true,
}; };
} }

69
frontend/src/app/shared/services/contributors.service.ts

@ -8,21 +8,19 @@
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators'; import { ApiUrlConfig, hasAnyLink, HTTP, mapVersioned, pretifyError, Resource, ResourceLinks, Version, Versioned } from '@app/framework';
import { AnalyticsService, ApiUrlConfig, hasAnyLink, HTTP, mapVersioned, pretifyError, Resource, ResourceLinks, Version, Versioned } from '@app/framework';
export class ContributorDto { export class ContributorDto {
public readonly _links: ResourceLinks; public readonly _links: ResourceLinks;
public readonly canUpdate: boolean;
public readonly canRevoke: boolean; public readonly canRevoke: boolean;
public readonly canUpdate: boolean;
public get token() { public get token() {
return `subject:${this.contributorId}`; return `subject:${this.contributorId}`;
} }
constructor( constructor(links: ResourceLinks,
links: ResourceLinks,
public readonly contributorId: string, public readonly contributorId: string,
public readonly contributorName: string, public readonly contributorName: string,
public readonly contributorEmail: string, public readonly contributorEmail: string,
@ -30,26 +28,43 @@ export class ContributorDto {
) { ) {
this._links = links; this._links = links;
this.canUpdate = hasAnyLink(links, 'update');
this.canRevoke = hasAnyLink(links, 'delete'); this.canRevoke = hasAnyLink(links, 'delete');
this.canUpdate = hasAnyLink(links, 'update');
} }
} }
export type ContributorsDto = export type ContributorsDto = Versioned<ContributorsPayload>;
Versioned<ContributorsPayload>;
export type ContributorsPayload = Readonly<{
// The list of contributors.
items: ReadonlyArray<ContributorDto>;
// The number of allowed contributors.
maxContributors: number;
export type ContributorsPayload = // True, if the user has been invited.
Readonly<{ items: ReadonlyArray<ContributorDto>; maxContributors: number; canCreate: boolean } & Resource>; isInvited?: boolean;
export type AssignContributorDto = // True, if the user has permission to create a contributor.
Readonly<{ contributorId: string; role: string; invite?: boolean }>; canCreate?: boolean;
}>;
export type AssignContributorDto = Readonly<{
// The user ID.
contributorId: string;
// The role for the contributor.
role: string;
// True, if the user should be invited.
invite?: boolean;
}>;
@Injectable() @Injectable()
export class ContributorsService { export class ContributorsService {
constructor( constructor(
private readonly http: HttpClient, private readonly http: HttpClient,
private readonly apiUrl: ApiUrlConfig, private readonly apiUrl: ApiUrlConfig,
private readonly analytics: AnalyticsService,
) { ) {
} }
@ -70,9 +85,6 @@ export class ContributorsService {
mapVersioned(({ body }) => { mapVersioned(({ body }) => {
return parseContributors(body); return parseContributors(body);
}), }),
tap(() => {
this.analytics.trackEvent('Contributor', 'Configured', appName);
}),
pretifyError('i18n:contributors.addFailed')); pretifyError('i18n:contributors.addFailed'));
} }
@ -85,22 +97,23 @@ export class ContributorsService {
mapVersioned(({ body }) => { mapVersioned(({ body }) => {
return parseContributors(body); return parseContributors(body);
}), }),
tap(() => {
this.analytics.trackEvent('Contributor', 'Deleted', appName);
}),
pretifyError('i18n:contributors.deleteFailed')); pretifyError('i18n:contributors.deleteFailed'));
} }
} }
function parseContributors(response: { items: any[]; maxContributors: number } & Resource) { function parseContributors(response: { items: any[]; maxContributors: number } & Resource): ContributorsPayload {
const items = response.items.map(item => const { items: list, maxContributors, _meta, _links } = response;
new ContributorDto(item._links, const items = list.map(parseContributor);
item.contributorId,
item.contributorName,
item.contributorEmail,
item.role));
const { maxContributors, _links, _meta } = response; const canCreate = hasAnyLink(_links, 'create');
return { items, maxContributors, _links, _meta, canCreate: hasAnyLink(_links, 'create') }; return { items, maxContributors, canCreate, isInvited: _meta?.['isInvited'] === '1' };
} }
function parseContributor(response: any) {
return new ContributorDto(response._links,
response.contributorId,
response.contributorName,
response.contributorEmail,
response.role);
}

21
frontend/src/app/shared/services/history.service.ts

@ -82,19 +82,22 @@ export class HistoryService {
return this.http.get<any[]>(url, options).pipe( return this.http.get<any[]>(url, options).pipe(
map(body => { map(body => {
return parseHistory(body); return parseHistoryEvents(body);
}), }),
pretifyError('i18n:history.loadFailed')); pretifyError('i18n:history.loadFailed'));
} }
} }
function parseHistory(response: any[]) { function parseHistoryEvents(response: any[]) {
return response.map(item => new HistoryEventDto( return response.map(parseHistoryEvent);
item.eventId,
item.actor,
item.eventType,
item.message,
DateTime.parseISO(item.created),
new Version(item.version.toString())));
} }
function parseHistoryEvent(response: any) {
return new HistoryEventDto(
response.eventId,
response.actor,
response.eventType,
response.message,
DateTime.parseISO(response.created),
new Version(response.version.toString()));
}

10
frontend/src/app/shared/services/languages.service.ts

@ -39,7 +39,11 @@ export class LanguagesService {
} }
function parseLanguages(response: any[]) { function parseLanguages(response: any[]) {
return response.map(item => new LanguageDto( return response.map(parseLanguage);
item.iso2Code, }
item.englishName));
function parseLanguage(response: any) {
return new LanguageDto(
response.iso2Code,
response.englishName);
} }

19
frontend/src/app/shared/services/news.service.spec.ts

@ -7,7 +7,7 @@
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { inject, TestBed } from '@angular/core/testing'; import { inject, TestBed } from '@angular/core/testing';
import { ApiUrlConfig, FeatureDto, FeaturesDto, NewsService } from '@app/shared/internal'; import { ApiUrlConfig, FeaturesDto, NewsService } from '@app/shared/internal';
describe('NewsService', () => { describe('NewsService', () => {
beforeEach(() => { beforeEach(() => {
@ -40,7 +40,6 @@ describe('NewsService', () => {
expect(req.request.headers.get('If-Match')).toBeNull(); expect(req.request.headers.get('If-Match')).toBeNull();
req.flush({ req.flush({
version: 13,
features: [{ features: [{
name: 'Feature1', name: 'Feature1',
text: 'Feature Text1', text: 'Feature Text1',
@ -48,12 +47,18 @@ describe('NewsService', () => {
name: 'Feature2', name: 'Feature2',
text: 'Feature Text2', text: 'Feature Text2',
}], }],
version: 13,
}); });
expect(features!).toEqual( expect(features!).toEqual({
new FeaturesDto([ features: [{
new FeatureDto('Feature1', 'Feature Text1'), name: 'Feature1',
new FeatureDto('Feature2', 'Feature Text2'), text: 'Feature Text1',
], 13)); }, {
name: 'Feature2',
text: 'Feature Text2',
}],
version: 13,
});
})); }));
}); });

44
frontend/src/app/shared/services/news.service.ts

@ -8,24 +8,23 @@
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { ApiUrlConfig, pretifyError } from '@app/framework'; import { ApiUrlConfig, pretifyError } from '@app/framework';
export class FeatureDto { export type FeatureDto = Readonly<{
constructor( // The name of the feature.
public readonly name: string, name: string;
public readonly text: string,
) {
}
}
export class FeaturesDto { // The feature description.
constructor( text: string;
public readonly features: ReadonlyArray<FeatureDto>, }>;
public readonly version: number,
) { export type FeaturesDto = Readonly<{
} // The list of features.
} features: ReadonlyArray<FeatureDto>;
// The latest version.
version: number;
}>;
@Injectable() @Injectable()
export class NewsService { export class NewsService {
@ -38,20 +37,7 @@ export class NewsService {
public getFeatures(version: number): Observable<FeaturesDto> { public getFeatures(version: number): Observable<FeaturesDto> {
const url = this.apiUrl.buildUrl(`api/news/features?version=${version}`); const url = this.apiUrl.buildUrl(`api/news/features?version=${version}`);
return this.http.get<any>(url).pipe( return this.http.get<FeaturesDto>(url).pipe(
map(body => {
return parseFeatures(body);
}),
pretifyError('i18n:features.loadFailed')); pretifyError('i18n:features.loadFailed'));
} }
} }
function parseFeatures(body: { features: any[]; version: number }) {
return new FeaturesDto(
body.features.map(item =>
new FeatureDto(
item.name,
item.text),
),
body.version);
}

3
frontend/src/app/shared/services/plans.service.spec.ts

@ -7,7 +7,7 @@
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { inject, TestBed } from '@angular/core/testing'; import { inject, TestBed } from '@angular/core/testing';
import { AnalyticsService, ApiUrlConfig, PlanChangedDto, PlanDto, PlansDto, PlansService, Version } from '@app/shared/internal'; import { ApiUrlConfig, PlanChangedDto, PlanDto, PlansDto, PlansService, Version } from '@app/shared/internal';
describe('PlansService', () => { describe('PlansService', () => {
const version = new Version('1'); const version = new Version('1');
@ -20,7 +20,6 @@ describe('PlansService', () => {
providers: [ providers: [
PlansService, PlansService,
{ provide: ApiUrlConfig, useValue: new ApiUrlConfig('http://service/p/') }, { provide: ApiUrlConfig, useValue: new ApiUrlConfig('http://service/p/') },
{ provide: AnalyticsService, useValue: new AnalyticsService() },
], ],
}); });
}); });

75
frontend/src/app/shared/services/plans.service.ts

@ -8,8 +8,7 @@
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators'; import { ApiUrlConfig, HTTP, mapVersioned, pretifyError, Version, Versioned } from '@app/framework';
import { AnalyticsService, ApiUrlConfig, HTTP, mapVersioned, pretifyError, Version, Versioned } from '@app/framework';
export class PlanDto { export class PlanDto {
constructor( constructor(
@ -28,21 +27,37 @@ export class PlanDto {
} }
} }
export type PlansDto = export type PlansDto = Versioned<PlansPayload>;
Versioned<Readonly<{ currentPlanId: string; planOwner: string; hasPortal: boolean; plans: ReadonlyArray<PlanDto> }>>;
export type PlanChangedDto = export type PlansPayload = Readonly<{
Readonly<{ redirectUri?: string }>; // The ID of the current plan.
currentPlanId: string;
export type ChangePlanDto = // The user, who owns the plan.
Readonly<{ planId: string }>; planOwner: string;
// True, if the installation has a billing portal.
hasPortal: boolean;
// The actual plans.
plans: ReadonlyArray<PlanDto>;
}>;
export type PlanChangedDto = Readonly<{
// The redirect URI.
redirectUri?: string;
}>;
export type ChangePlanDto = Readonly<{
// The new plan ID.
planId: string;
}>;
@Injectable() @Injectable()
export class PlansService { export class PlansService {
constructor( constructor(
private readonly http: HttpClient, private readonly http: HttpClient,
private readonly apiUrl: ApiUrlConfig, private readonly apiUrl: ApiUrlConfig,
private readonly analytics: AnalyticsService,
) { ) {
} }
@ -63,32 +78,28 @@ export class PlansService {
mapVersioned(({ body }) => { mapVersioned(({ body }) => {
return <PlanChangedDto>body; return <PlanChangedDto>body;
}), }),
tap(() => {
this.analytics.trackEvent('Plan', 'Changed', appName);
}),
pretifyError('i18n:plans.changeFailed')); pretifyError('i18n:plans.changeFailed'));
} }
} }
function parsePlans(body: { plans: any[]; hasPortal: boolean; currentPlanId: string; planOwner: string }) { function parsePlans(response: { plans: any[]; hasPortal: boolean; currentPlanId: string; planOwner: string }): PlansPayload {
const { hasPortal, currentPlanId, planOwner } = body; const { plans: list, currentPlanId, hasPortal, planOwner } = response;
const plans = list.map(parsePlan);
return {
currentPlanId, return { plans, planOwner, currentPlanId, hasPortal };
planOwner,
plans: body.plans.map(item => new PlanDto(
item.id,
item.name,
item.costs,
item.confirmText,
item.yearlyId,
item.yearlyCosts,
item.yearlyConfirmText,
item.maxApiBytes,
item.maxApiCalls,
item.maxAssetSize,
item.maxContributors)),
hasPortal,
};
} }
function parsePlan(response: any) {
return new PlanDto(
response.id,
response.name,
response.costs,
response.confirmText,
response.yearlyId,
response.yearlyCosts,
response.yearlyConfirmText,
response.maxApiBytes,
response.maxApiCalls,
response.maxAssetSize,
response.maxContributors);
}

2
frontend/src/app/shared/services/query.ts

@ -31,7 +31,7 @@ export type FilterFieldUI =
'Status' | 'Status' |
'Unsupported' | 'Unsupported' |
'User'; 'User';
export function getFilterUI(comparison: FilterComparison, field: FilterableField): FilterFieldUI { export function getFilterUI(comparison: FilterComparison, field: FilterableField): FilterFieldUI {
if (!field || !comparison) { if (!field || !comparison) {
return 'None'; return 'None';

6
frontend/src/app/shared/services/roles.service.spec.ts

@ -7,7 +7,7 @@
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { inject, TestBed } from '@angular/core/testing'; import { inject, TestBed } from '@angular/core/testing';
import { AnalyticsService, ApiUrlConfig, Resource, ResourceLinks, RoleDto, RolesDto, RolesPayload, RolesService, Version } from '@app/shared/internal'; import { ApiUrlConfig, Resource, ResourceLinks, RoleDto, RolesDto, RolesPayload, RolesService, Version } from '@app/shared/internal';
describe('RolesService', () => { describe('RolesService', () => {
const version = new Version('1'); const version = new Version('1');
@ -20,7 +20,6 @@ describe('RolesService', () => {
providers: [ providers: [
RolesService, RolesService,
{ provide: ApiUrlConfig, useValue: new ApiUrlConfig('http://service/p/') }, { provide: ApiUrlConfig, useValue: new ApiUrlConfig('http://service/p/') },
{ provide: AnalyticsService, useValue: new AnalyticsService() },
], ],
}); });
}); });
@ -174,9 +173,6 @@ describe('RolesService', () => {
export function createRoles(...ids: ReadonlyArray<number>): RolesPayload { export function createRoles(...ids: ReadonlyArray<number>): RolesPayload {
return { return {
items: ids.map(createRole), items: ids.map(createRole),
_links: {
create: { method: 'POST', href: '/roles' },
},
canCreate: true, canCreate: true,
}; };
} }

71
frontend/src/app/shared/services/roles.service.ts

@ -8,8 +8,7 @@
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators'; import { ApiUrlConfig, hasAnyLink, HTTP, mapVersioned, pretifyError, Resource, ResourceLinks, Version, Versioned } from '@app/framework';
import { AnalyticsService, ApiUrlConfig, hasAnyLink, HTTP, mapVersioned, pretifyError, Resource, ResourceLinks, Version, Versioned } from '@app/framework';
export class RoleDto { export class RoleDto {
public readonly _links: ResourceLinks; public readonly _links: ResourceLinks;
@ -17,8 +16,7 @@ export class RoleDto {
public readonly canDelete: boolean; public readonly canDelete: boolean;
public readonly canUpdate: boolean; public readonly canUpdate: boolean;
constructor( constructor(links: ResourceLinks,
links: ResourceLinks,
public readonly name: string, public readonly name: string,
public readonly numClients: number, public readonly numClients: number,
public readonly numContributors: number, public readonly numContributors: number,
@ -33,26 +31,34 @@ export class RoleDto {
} }
} }
type Permissions = readonly string[]; export type RolesDto = Versioned<RolesPayload>;
export type RolesPayload = Readonly<{
// The list of roles.
items: ReadonlyArray<RoleDto>;
export type RolesDto = // True, if the user has permissions to create a new role.
Versioned<RolesPayload>; canCreate?: boolean;
}>;
export type RolesPayload = export type CreateRoleDto = Readonly<{
Readonly<{ items: ReadonlyArray<RoleDto>; canCreate: boolean } & Resource>; // The name of the role, cannot be changed later.
name: string;
}>;
export type CreateRoleDto = export type UpdateRoleDto = Readonly<{
Readonly<{ name: string }>; // The permissions in dot notation.
permissions: ReadonlyArray<string>;
export type UpdateRoleDto = // The UI properties.
Readonly<{ permissions: Permissions; properties: {} }>; properties: {};
}>;
@Injectable() @Injectable()
export class RolesService { export class RolesService {
constructor( constructor(
private readonly http: HttpClient, private readonly http: HttpClient,
private readonly apiUrl: ApiUrlConfig, private readonly apiUrl: ApiUrlConfig,
private readonly analytics: AnalyticsService,
) { ) {
} }
@ -73,9 +79,6 @@ export class RolesService {
mapVersioned(({ body }) => { mapVersioned(({ body }) => {
return parseRoles(body); return parseRoles(body);
}), }),
tap(() => {
this.analytics.trackEvent('Role', 'Created', appName);
}),
pretifyError('i18n:roles.addFailed')); pretifyError('i18n:roles.addFailed'));
} }
@ -88,9 +91,6 @@ export class RolesService {
mapVersioned(({ body }) => { mapVersioned(({ body }) => {
return parseRoles(body); return parseRoles(body);
}), }),
tap(() => {
this.analytics.trackEvent('Role', 'Updated', appName);
}),
pretifyError('i18n:roles.updateFailed')); pretifyError('i18n:roles.updateFailed'));
} }
@ -103,9 +103,6 @@ export class RolesService {
mapVersioned(({ body }) => { mapVersioned(({ body }) => {
return parseRoles(body); return parseRoles(body);
}), }),
tap(() => {
this.analytics.trackEvent('Role', 'Deleted', appName);
}),
pretifyError('i18n:roles.revokeFailed')); pretifyError('i18n:roles.revokeFailed'));
} }
@ -117,19 +114,21 @@ export class RolesService {
} }
} }
export function parseRoles(response: any) { function parseRoles(response: { items: any } & Resource): RolesPayload {
const raw: any[] = response.items; const { items: list, _links } = response;
const items = list.map(parseRole);
const items = raw.map(item =>
new RoleDto(item._links,
item.name,
item.numClients,
item.numContributors,
item.permissions,
item.properties,
item.isDefaultRole));
const { _links } = response; const canCreate = hasAnyLink(_links, 'create');
return { items, _links, canCreate: hasAnyLink(_links, 'create') }; return { items, canCreate };
} }
function parseRole(response: any) {
return new RoleDto(response._links,
response.name,
response.numClients,
response.numContributors,
response.permissions,
response.properties,
response.isDefaultRole);
}

45
frontend/src/app/shared/services/rules.service.spec.ts

@ -7,7 +7,7 @@
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { inject, TestBed } from '@angular/core/testing'; import { inject, TestBed } from '@angular/core/testing';
import { AnalyticsService, ApiUrlConfig, DateTime, Resource, ResourceLinks, RuleDto, RuleElementDto, RuleElementPropertyDto, RuleEventDto, RuleEventsDto, RulesDto, RulesService, Version } from '@app/shared/internal'; import { ApiUrlConfig, DateTime, Resource, ResourceLinks, RuleDto, RuleElementDto, RuleElementPropertyDto, RuleEventDto, RuleEventsDto, RulesDto, RulesService, Version } from '@app/shared/internal';
import { RuleCompletions } from '..'; import { RuleCompletions } from '..';
import { SimulatedRuleEventDto, SimulatedRuleEventsDto } from './rules.service'; import { SimulatedRuleEventDto, SimulatedRuleEventsDto } from './rules.service';
@ -22,7 +22,6 @@ describe('RulesService', () => {
providers: [ providers: [
RulesService, RulesService,
{ provide: ApiUrlConfig, useValue: new ApiUrlConfig('http://service/p/') }, { provide: ApiUrlConfig, useValue: new ApiUrlConfig('http://service/p/') },
{ provide: AnalyticsService, useValue: new AnalyticsService() },
], ],
}); });
}); });
@ -117,11 +116,16 @@ describe('RulesService', () => {
runningRuleId: '12', runningRuleId: '12',
}); });
expect(rules!).toEqual( expect(rules!).toEqual({
new RulesDto([ items: [
createRule(12), createRule(12),
createRule(13), createRule(13),
], {}, '12')); ],
runningRuleId: '12',
canCancelRun: false,
canCreate: false,
canReadEvents: false,
});
})); }));
it('should make post request to create rule', it('should make post request to create rule',
@ -290,13 +294,22 @@ describe('RulesService', () => {
ruleEventResponse(1), ruleEventResponse(1),
ruleEventResponse(2), ruleEventResponse(2),
], ],
_links: {
cancel: { method: 'DELETE', href: '/rules/events' },
},
}); });
expect(rules!).toEqual( expect(rules!).toEqual({
new RuleEventsDto(20, [ items: [
createRuleEvent(1), createRuleEvent(1),
createRuleEvent(2), createRuleEvent(2),
])); ],
_links: {
cancel: { method: 'DELETE', href: '/rules/events' },
},
total: 20,
canCancelAll: false,
});
})); }));
it('should make get request to get simulated rule events', it('should make get request to get simulated rule events',
@ -319,11 +332,13 @@ describe('RulesService', () => {
], ],
}); });
expect(rules!).toEqual( expect(rules!).toEqual({
new SimulatedRuleEventsDto(20, [ items: [
createSimulatedRuleEvent(1), createSimulatedRuleEvent(1),
createSimulatedRuleEvent(2), createSimulatedRuleEvent(2),
])); ],
total: 20,
});
})); }));
it('should make post request to get simulated rule events with action and trigger', it('should make post request to get simulated rule events with action and trigger',
@ -346,11 +361,13 @@ describe('RulesService', () => {
], ],
}); });
expect(rules!).toEqual( expect(rules!).toEqual({
new SimulatedRuleEventsDto(20, [ items: [
createSimulatedRuleEvent(1), createSimulatedRuleEvent(1),
createSimulatedRuleEvent(2), createSimulatedRuleEvent(2),
])); ],
total: 20,
});
})); }));
it('should make put request to enqueue rule event', it('should make put request to enqueue rule event',

175
frontend/src/app/shared/services/rules.service.ts

@ -8,8 +8,8 @@
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { map, tap } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { AnalyticsService, ApiUrlConfig, DateTime, hasAnyLink, HTTP, Model, pretifyError, Resource, ResourceLinks, ResultSet, Version } from '@app/framework'; import { ApiUrlConfig, DateTime, hasAnyLink, HTTP, Model, pretifyError, Resource, ResourceLinks, Version } from '@app/framework';
export type RuleElementMetadataDto = Readonly<{ export type RuleElementMetadataDto = Readonly<{
description: string; description: string;
@ -101,26 +101,6 @@ export class RuleElementPropertyDto {
} }
} }
export class RulesDto extends ResultSet<RuleDto> {
public get canCreate() {
return hasAnyLink(this._links, 'create');
}
public get canReadEvents() {
return hasAnyLink(this._links, 'events');
}
public get canCancelRun() {
return hasAnyLink(this._links, 'run/cancel');
}
constructor(items: ReadonlyArray<RuleDto>, links?: {},
public readonly runningRuleId?: string,
) {
super(items.length, items, links);
}
}
export class RuleDto { export class RuleDto {
public readonly _links: ResourceLinks; public readonly _links: ResourceLinks;
@ -133,8 +113,7 @@ export class RuleDto {
public readonly canTrigger: boolean; public readonly canTrigger: boolean;
public readonly canUpdate: boolean; public readonly canUpdate: boolean;
constructor( constructor(links: ResourceLinks,
links: ResourceLinks,
public readonly id: string, public readonly id: string,
public readonly created: DateTime, public readonly created: DateTime,
public readonly createdBy: string, public readonly createdBy: string,
@ -164,9 +143,6 @@ export class RuleDto {
} }
} }
export class RuleEventsDto extends ResultSet<RuleEventDto> {
}
export class RuleEventDto extends Model<RuleEventDto> { export class RuleEventDto extends Model<RuleEventDto> {
public readonly _links: ResourceLinks; public readonly _links: ResourceLinks;
@ -193,9 +169,6 @@ export class RuleEventDto extends Model<RuleEventDto> {
} }
} }
export class SimulatedRuleEventsDto extends ResultSet<SimulatedRuleEventDto> {
}
export class SimulatedRuleEventDto { export class SimulatedRuleEventDto {
public readonly _links: ResourceLinks; public readonly _links: ResourceLinks;
@ -213,27 +186,93 @@ export class SimulatedRuleEventDto {
} }
} }
export type RuleCompletions = export type RuleCompletions = ReadonlyArray<Readonly<{
ReadonlyArray<{ path: string; description: string; type: string }>; // The autocompletion path.
path: string;
// The description of the autocompletion field.
description: string;
// The type of the autocompletion field.
type: string;
}>>;
export type RulesDto = Readonly<{
// The list of rules.
items: ReadonlyArray<RuleDto>;
// The id of the rule that is currently running.
runningRuleId?: string;
// True, if the user has permission to create a rule.
canCreate?: boolean;
// True, if the user has permission to read events.
canReadEvents?: boolean;
// True, if the user has permission to cancel an event.
canCancelRun?: boolean;
}>;
export type RuleEventsDto = Readonly<{
// The list of rule events.
items: ReadonlyArray<RuleEventDto>;
// The total number of rule events.
total: number;
// True, if the user has permissions to cancel all rule events.
canCancelAll?: boolean;
}> & Resource;
export type SimulatedRuleEventsDto = Readonly<{
// The list of simulated rule events.
items: ReadonlyArray<SimulatedRuleEventDto>;
export type ActionsDto = // The total number of simulated rule events.
Readonly<{ [name: string]: RuleElementDto }>; total: number;
}>;
export type ActionsDto = Readonly<{
// The rule elements by name.
[name: string]: RuleElementDto;
}>;
export type UpsertRuleDto = export type UpsertRuleDto = Readonly<{
Readonly<{ trigger?: RuleTrigger; action?: RuleAction; name?: string; isEnabled?: boolean }>; // The optional trigger to update.
trigger?: RuleTrigger;
export type RuleAction = // The optional action to update.
Readonly<{ actionType: string; [key: string]: any }>; action?: RuleAction;
export type RuleTrigger = // The optional rule name.
Readonly<{ triggerType: string; [key: string]: any }>; name?: string;
// True, if the rule is enabled.
isEnabled?: boolean;
}>;
export type RuleAction = Readonly<{
// The type of the action.
actionType: string;
// The additional properties.
[key: string]: any;
}>;
export type RuleTrigger = Readonly<{
// The type of the trigger.
triggerType: string;
// The additional properties.
[key: string]: any;
}>;
@Injectable() @Injectable()
export class RulesService { export class RulesService {
constructor( constructor(
private readonly http: HttpClient, private readonly http: HttpClient,
private readonly apiUrl: ApiUrlConfig, private readonly apiUrl: ApiUrlConfig,
private readonly analytics: AnalyticsService,
) { ) {
} }
@ -264,9 +303,6 @@ export class RulesService {
map(({ payload }) => { map(({ payload }) => {
return parseRule(payload.body); return parseRule(payload.body);
}), }),
tap(() => {
this.analytics.trackEvent('Rule', 'Created', appName);
}),
pretifyError('i18n:rules.createFailed')); pretifyError('i18n:rules.createFailed'));
} }
@ -279,9 +315,6 @@ export class RulesService {
map(({ payload }) => { map(({ payload }) => {
return parseRule(payload.body); return parseRule(payload.body);
}), }),
tap(() => {
this.analytics.trackEvent('Rule', 'Updated', appName);
}),
pretifyError('i18n:rules.updateFailed')); pretifyError('i18n:rules.updateFailed'));
} }
@ -291,9 +324,6 @@ export class RulesService {
const url = this.apiUrl.buildUrl(link.href); const url = this.apiUrl.buildUrl(link.href);
return HTTP.requestVersioned(this.http, link.method, url, version).pipe( return HTTP.requestVersioned(this.http, link.method, url, version).pipe(
tap(() => {
this.analytics.trackEvent('Rule', 'Deleted', appName);
}),
pretifyError('i18n:rules.deleteFailed')); pretifyError('i18n:rules.deleteFailed'));
} }
@ -303,9 +333,6 @@ export class RulesService {
const url = this.apiUrl.buildUrl(link.href); const url = this.apiUrl.buildUrl(link.href);
return this.http.request(link.method, url, {}).pipe( return this.http.request(link.method, url, {}).pipe(
tap(() => {
this.analytics.trackEvent('Rule', 'Run', appName);
}),
pretifyError('i18n:rules.runFailed')); pretifyError('i18n:rules.runFailed'));
} }
@ -315,9 +342,6 @@ export class RulesService {
const url = this.apiUrl.buildUrl(link.href); const url = this.apiUrl.buildUrl(link.href);
return this.http.request(link.method, url, {}).pipe( return this.http.request(link.method, url, {}).pipe(
tap(() => {
this.analytics.trackEvent('Rule', 'Run', appName);
}),
pretifyError('i18n:rules.runFailed')); pretifyError('i18n:rules.runFailed'));
} }
@ -325,9 +349,6 @@ export class RulesService {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/rules/run`); const url = this.apiUrl.buildUrl(`api/apps/${appName}/rules/run`);
return this.http.delete(url).pipe( return this.http.delete(url).pipe(
tap(() => {
this.analytics.trackEvent('Rule', 'RunCancel', appName);
}),
pretifyError('i18n:rules.cancelFailed')); pretifyError('i18n:rules.cancelFailed'));
} }
@ -337,9 +358,6 @@ export class RulesService {
const url = this.apiUrl.buildUrl(link.href); const url = this.apiUrl.buildUrl(link.href);
return this.http.request(link.method, url, {}).pipe( return this.http.request(link.method, url, {}).pipe(
tap(() => {
this.analytics.trackEvent('Rule', 'Triggered', appName);
}),
pretifyError('i18n:rules.triggerFailed')); pretifyError('i18n:rules.triggerFailed'));
} }
@ -379,9 +397,6 @@ export class RulesService {
const url = this.apiUrl.buildUrl(link.href); const url = this.apiUrl.buildUrl(link.href);
return HTTP.requestVersioned(this.http, link.method, url).pipe( return HTTP.requestVersioned(this.http, link.method, url).pipe(
tap(() => {
this.analytics.trackEvent('Rule', 'EventEnqueued', appName);
}),
pretifyError('i18n:rules.ruleEvents.enqueueFailed')); pretifyError('i18n:rules.ruleEvents.enqueueFailed'));
} }
@ -391,9 +406,6 @@ export class RulesService {
const url = this.apiUrl.buildUrl(link.href); const url = this.apiUrl.buildUrl(link.href);
return HTTP.requestVersioned(this.http, link.method, url).pipe( return HTTP.requestVersioned(this.http, link.method, url).pipe(
tap(() => {
this.analytics.trackEvent('Rule', 'EventsCancelled', appName);
}),
pretifyError('i18n:rules.ruleEvents.cancelFailed')); pretifyError('i18n:rules.ruleEvents.cancelFailed'));
} }
@ -404,22 +416,31 @@ export class RulesService {
} }
} }
function parseSimulatedEvents(response: { items: any[]; total: number } & Resource) { function parseSimulatedEvents(response: { items: any[]; total: number } & Resource): SimulatedRuleEventsDto {
const simulatedRuleEvents = response.items.map(parseSimulatedRuleEvent); const { items: list, total } = response;
const items = list.map(parseSimulatedRuleEvent);
return new SimulatedRuleEventsDto(response.total, simulatedRuleEvents, response._links); return { items, total };
} }
function parseEvents(response: { items: any[]; total: number } & Resource) { function parseEvents(response: { items: any[]; total: number } & Resource): RuleEventsDto {
const ruleEvents = response.items.map(parseRuleEvent); const { items: list, total, _links } = response;
const items = list.map(parseRuleEvent);
const canCancelAll = hasAnyLink(_links, 'create');
return new RuleEventsDto(response.total, ruleEvents, response._links); return { items, total, canCancelAll, _links };
} }
function parseRules(response: { items: any[]; runningRuleId?: string } & Resource) { function parseRules(response: { items: any[]; runningRuleId?: string } & Resource): RulesDto {
const rules = response.items.map(parseRule); const { items: list, runningRuleId, _links } = response;
const items = list.map(parseRule);
const canCreate = hasAnyLink(_links, 'create');
const canReadEvents = hasAnyLink(_links, 'events');
const canCancelRun = hasAnyLink(_links, 'run/cancel');
return new RulesDto(rules, response._links, response.runningRuleId); return { items, runningRuleId, canCreate, canCancelRun, canReadEvents };
} }
function parseActions(response: any) { function parseActions(response: any) {

8
frontend/src/app/shared/services/schemas.service.spec.ts

@ -7,7 +7,7 @@
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { inject, TestBed } from '@angular/core/testing'; import { inject, TestBed } from '@angular/core/testing';
import { AnalyticsService, ApiUrlConfig, createProperties, DateTime, FieldRule, NestedFieldDto, Resource, ResourceLinks, RootFieldDto, SchemaDto, SchemaPropertiesDto, SchemasDto, SchemasService, Version } from '@app/shared/internal'; import { ApiUrlConfig, createProperties, DateTime, FieldRule, NestedFieldDto, Resource, ResourceLinks, RootFieldDto, SchemaDto, SchemaPropertiesDto, SchemasDto, SchemasService, Version } from '@app/shared/internal';
import { SchemaCompletions } from '..'; import { SchemaCompletions } from '..';
describe('SchemasService', () => { describe('SchemasService', () => {
@ -21,7 +21,6 @@ describe('SchemasService', () => {
providers: [ providers: [
SchemasService, SchemasService,
{ provide: ApiUrlConfig, useValue: new ApiUrlConfig('http://service/p/') }, { provide: ApiUrlConfig, useValue: new ApiUrlConfig('http://service/p/') },
{ provide: AnalyticsService, useValue: new AnalyticsService() },
], ],
}); });
}); });
@ -60,14 +59,11 @@ describe('SchemasService', () => {
}); });
expect(schemas!).toEqual({ expect(schemas!).toEqual({
canCreate: true,
items: [ items: [
createSchema(12), createSchema(12),
createSchema(13), createSchema(13),
], ],
_links: { canCreate: true,
create: { method: 'POST', href: '/schemas' },
},
}); });
})); }));

265
frontend/src/app/shared/services/schemas.service.ts

@ -8,12 +8,17 @@
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { map, tap } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { AnalyticsService, ApiUrlConfig, DateTime, hasAnyLink, HTTP, pretifyError, Resource, ResourceLinks, StringHelper, Types, Version, Versioned } from '@app/framework'; import { ApiUrlConfig, DateTime, hasAnyLink, HTTP, pretifyError, Resource, ResourceLinks, StringHelper, Types, Version, Versioned } from '@app/framework';
import { QueryModel } from './query'; import { QueryModel } from './query';
import { createProperties, FieldPropertiesDto } from './schemas.types'; import { createProperties, FieldPropertiesDto } from './schemas.types';
export const MetaFields = { export type FieldRuleAction = 'Disable' | 'Hide' | 'Require';
export type SchemaType = 'Default' | 'Singleton' | 'Component';
export type SchemaScripts = Record<string, string | null>;
export type PreviewUrls = Record<string, string>;
export const META_FIELDS = {
empty: { empty: {
name: '', name: '',
label: '', label: '',
@ -64,17 +69,19 @@ export const MetaFields = {
}, },
}; };
export type SchemaType = 'Default' | 'Singleton' | 'Component'; export const FIELD_RULE_ACTIONS: ReadonlyArray<FieldRuleAction> = [
export type SchemaScripts = Record<string, string | null>; 'Disable',
export type PreviewUrls = Record<string, string>; 'Hide',
'Require',
];
export class SchemaDto { export class SchemaDto {
public readonly _links: ResourceLinks; public readonly _links: ResourceLinks;
public readonly canAddField: boolean; public readonly canAddField: boolean;
public readonly canContentsRead: boolean;
public readonly canContentsCreate: boolean; public readonly canContentsCreate: boolean;
public readonly canContentsCreateAndPublish: boolean; public readonly canContentsCreateAndPublish: boolean;
public readonly canContentsRead: boolean;
public readonly canDelete: boolean; public readonly canDelete: boolean;
public readonly canOrderFields: boolean; public readonly canOrderFields: boolean;
public readonly canPublish: boolean; public readonly canPublish: boolean;
@ -83,10 +90,10 @@ export class SchemaDto {
public readonly canUnpublish: boolean; public readonly canUnpublish: boolean;
public readonly canUpdate: boolean; public readonly canUpdate: boolean;
public readonly canUpdateCategory: boolean; public readonly canUpdateCategory: boolean;
public readonly canUpdateRules: boolean;
public readonly canUpdateScripts: boolean; public readonly canUpdateScripts: boolean;
public readonly canUpdateUIFields: boolean; public readonly canUpdateUIFields: boolean;
public readonly canUpdateUrls: boolean; public readonly canUpdateUrls: boolean;
public readonly canUpdateRules: boolean;
public readonly displayName: string; public readonly displayName: string;
@ -108,8 +115,8 @@ export class SchemaDto {
public readonly isPublished: boolean, public readonly isPublished: boolean,
public readonly properties: SchemaPropertiesDto, public readonly properties: SchemaPropertiesDto,
public readonly fields: ReadonlyArray<RootFieldDto> = [], public readonly fields: ReadonlyArray<RootFieldDto> = [],
public readonly fieldsInLists: Tags = [], public readonly fieldsInLists: ReadonlyArray<string> = [],
public readonly fieldsInReferences: Tags = [], public readonly fieldsInReferences: ReadonlyArray<string> = [],
public readonly fieldRules: ReadonlyArray<FieldRule> = [], public readonly fieldRules: ReadonlyArray<FieldRule> = [],
public readonly previewUrls: PreviewUrls = {}, public readonly previewUrls: PreviewUrls = {},
public readonly scripts: SchemaScripts = {}, public readonly scripts: SchemaScripts = {},
@ -117,9 +124,9 @@ export class SchemaDto {
this._links = links; this._links = links;
this.canAddField = hasAnyLink(links, 'fields/add'); this.canAddField = hasAnyLink(links, 'fields/add');
this.canContentsRead = hasAnyLink(links, 'contents');
this.canContentsCreate = hasAnyLink(links, 'contents/create'); this.canContentsCreate = hasAnyLink(links, 'contents/create');
this.canContentsCreateAndPublish = hasAnyLink(links, 'contents/create/publish'); this.canContentsCreateAndPublish = hasAnyLink(links, 'contents/create/publish');
this.canContentsRead = hasAnyLink(links, 'contents');
this.canDelete = hasAnyLink(links, 'delete'); this.canDelete = hasAnyLink(links, 'delete');
this.canOrderFields = hasAnyLink(links, 'fields/order'); this.canOrderFields = hasAnyLink(links, 'fields/order');
this.canPublish = hasAnyLink(links, 'publish'); this.canPublish = hasAnyLink(links, 'publish');
@ -128,49 +135,53 @@ export class SchemaDto {
this.canUnpublish = hasAnyLink(links, 'unpublish'); this.canUnpublish = hasAnyLink(links, 'unpublish');
this.canUpdate = hasAnyLink(links, 'update'); this.canUpdate = hasAnyLink(links, 'update');
this.canUpdateCategory = hasAnyLink(links, 'update/category'); this.canUpdateCategory = hasAnyLink(links, 'update/category');
this.canUpdateRules = hasAnyLink(links, 'update/rules');
this.canUpdateScripts = hasAnyLink(links, 'update/scripts'); this.canUpdateScripts = hasAnyLink(links, 'update/scripts');
this.canUpdateUIFields = hasAnyLink(links, 'fields/ui'); this.canUpdateUIFields = hasAnyLink(links, 'fields/ui');
this.canUpdateUrls = hasAnyLink(links, 'update/urls'); this.canUpdateUrls = hasAnyLink(links, 'update/urls');
this.canUpdateRules = hasAnyLink(links, 'update/rules');
this.displayName = StringHelper.firstNonEmpty(this.properties.label, this.name); this.displayName = StringHelper.firstNonEmpty(this.properties.label, this.name);
function tableField(rootField: RootFieldDto) {
return { name: rootField.name, label: rootField.displayName, rootField };
}
if (fields) { if (fields) {
this.contentFields = fields.filter(x => x.properties.isContentField).map(tableField); this.contentFields = fields.filter(x => x.properties.isContentField).map(tableField);
function tableFields(names: ReadonlyArray<string>, fields: ReadonlyArray<RootFieldDto>): TableField[] { function tableFields(names: ReadonlyArray<string>, fields: ReadonlyArray<RootFieldDto>): TableField[] {
const result: TableField[] = []; const result: TableField[] = [];
for (const name of names) { for (const name of names) {
const metaField = MetaFields[name]; const metaField = META_FIELDS[name];
if (metaField) { if (metaField) {
result.push(metaField); result.push(metaField);
} else { } else {
const field = fields.find(x => x.name === name && x.properties.isContentField); const field = fields.find(x => x.name === name && x.properties.isContentField);
if (field) { if (field) {
result.push(tableField(field)); result.push(tableField(field));
} }
} }
} }
return result; return result;
} }
const listFields = tableFields(fieldsInLists, fields); const listFields = tableFields(fieldsInLists, fields);
if (listFields.length === 0) { if (listFields.length === 0) {
listFields.push(MetaFields.lastModifiedByAvatar); listFields.push(META_FIELDS.lastModifiedByAvatar);
if (fields.length > 0) { if (fields.length > 0) {
listFields.push(tableField(this.fields[0])); listFields.push(tableField(this.fields[0]));
} else { } else {
listFields.push(MetaFields.empty); listFields.push(META_FIELDS.empty);
} }
listFields.push(MetaFields.statusColor); listFields.push(META_FIELDS.statusColor);
listFields.push(MetaFields.lastModified); listFields.push(META_FIELDS.lastModified);
} }
this.defaultListFields = listFields; this.defaultListFields = listFields;
@ -181,7 +192,7 @@ export class SchemaDto {
if (fields.length > 0) { if (fields.length > 0) {
referenceFields.push(tableField(this.fields[0])); referenceFields.push(tableField(this.fields[0]));
} else { } else {
referenceFields.push(MetaFields.empty); referenceFields.push(META_FIELDS.empty);
} }
} }
@ -246,10 +257,6 @@ export class SchemaDto {
} }
} }
export function tableField(rootField: RootFieldDto) {
return { name: rootField.name, label: rootField.displayName, rootField };
}
export class FieldDto { export class FieldDto {
public readonly _links: ResourceLinks; public readonly _links: ResourceLinks;
@ -341,52 +348,135 @@ export class SchemaPropertiesDto {
} }
} }
export const FIELD_RULE_ACTIONS: ReadonlyArray<FieldRuleAction> = [ export type TableField = Readonly<{
'Disable', // The name of the table field.
'Hide', name: string;
'Require',
];
type Tags = readonly string[]; // The label for the table header.
label: string;
export type TableField = { name: string; label: string; rootField?: RootFieldDto }; // The reference to the root field.
rootField?: RootFieldDto;
}>;
export type FieldRuleAction = 'Disable' | 'Hide' | 'Require'; export type FieldRule = Readonly<{
export type FieldRule = { field: string; action: FieldRuleAction; condition: string }; // The path to the field to update when the rule is valid.
field: string;
// The action to invoke.
action: FieldRuleAction;
//The condition as javascript expression.
condition: string;
}>;
export type SchemaCompletions = ReadonlyArray<{
// The autocompletion path.
path: string;
export type SchemaCompletions = // The description of the autocompletion field.
ReadonlyArray<{ path: string; description: string; type: string }>; description: string;
export type SchemasDto = // The type of the autocompletion field.
Readonly<{ items: ReadonlyArray<SchemaDto>; canCreate: boolean } & Resource>; type: string;
}>;
export type AddFieldDto = export type SchemasDto = Readonly<{
Readonly<{ name: string; partitioning?: string; properties: FieldPropertiesDto }>; // The list of schemas.
items: ReadonlyArray<SchemaDto>;
export type UpdateUIFields = // True, if the user has permissions to create a new schema.
Readonly<{ fieldsInLists?: Tags; fieldsInReferences?: Tags }>; canCreate?: boolean;
}>;
export type CreateSchemaDto = export type AddFieldDto = Readonly<{
Readonly<{ name: string; fields?: ReadonlyArray<RootFieldDto>; category?: string; type?: string; isPublished?: boolean; properties?: SchemaPropertiesDto }>; // The name of the field.
name: string;
export type UpdateSchemaCategoryDto = // The partitioning of the field.
Readonly<{ name?: string }>; partitioning?: string;
export type UpdateFieldDto = // The field properties.
Readonly<{ properties: FieldPropertiesDto }>; properties: FieldPropertiesDto;
}>;
export type SynchronizeSchemaDto = export type UpdateUIFields = Readonly<{
Readonly<{ noFieldDeletiong?: boolean; noFieldRecreation?: boolean; [key: string]: any }>; // The names of all fields that should be shown in the list.
fieldsInLists?: ReadonlyArray<string>;
export type UpdateSchemaDto = // The names of all fields that should be shown in the reference list.
Readonly<{ label?: string; hints?: string; contentsSidebarUrl?: string; contentSidebarUrl?: string; contentEditorUrl?: string; validateOnPublish?: boolean; tags?: Tags }>; fieldsInReferences?: ReadonlyArray<string>;
}>;
export type CreateSchemaDto = Readonly<{
// The name of the schema.
name: string;
// The initial fields of the schema.
fields?: ReadonlyArray<RootFieldDto>;
// The category name.
category?: string;
// The type of the schema.
type?: string;
// The initial published state.
isPublished?: boolean;
// The initial schema properties.
properties?: SchemaPropertiesDto;
}>;
export type UpdateSchemaCategoryDto = Readonly<{
// The name of the category.
name?: string;
}>;
export type UpdateFieldDto = Readonly<{
// The field properties.
properties: FieldPropertiesDto;
}>;
export type SynchronizeSchemaDto = Readonly<{
// True, to not delete fields when synchronizing.
noFieldDeletiong?: boolean;
// True, to not recreate fields when synchronizing.
noFieldRecreation?: boolean;
// The additional properties.
[key: string]: any;
}>;
export type UpdateSchemaDto = Readonly<{
// The label of the schema.
label?: string;
// The hints to explain the schema.
hints?: string;
// The URL to the contents sidebar plugin.
contentsSidebarUrl?: string;
// The URL to the content sidebar plugin.
contentSidebarUrl?: string;
// The URL to an editor to replace the editor.
contentEditorUrl?: string;
// True, if the content should be validated on publishing.
validateOnPublish?: boolean;
// The tags.
tags?: ReadonlyArray<string>;
}>;
@Injectable() @Injectable()
export class SchemasService { export class SchemasService {
constructor( constructor(
private readonly http: HttpClient, private readonly http: HttpClient,
private readonly apiUrl: ApiUrlConfig, private readonly apiUrl: ApiUrlConfig,
private readonly analytics: AnalyticsService,
) { ) {
} }
@ -417,9 +507,6 @@ export class SchemasService {
map(({ payload }) => { map(({ payload }) => {
return parseSchema(payload.body); return parseSchema(payload.body);
}), }),
tap(() => {
this.analytics.trackEvent('Schema', 'Created', appName);
}),
pretifyError('i18n:schemas.createFailed')); pretifyError('i18n:schemas.createFailed'));
} }
@ -432,9 +519,6 @@ export class SchemasService {
map(({ payload }) => { map(({ payload }) => {
return parseSchema(payload.body); return parseSchema(payload.body);
}), }),
tap(() => {
this.analytics.trackEvent('Schema', 'ScriptsConfigured', appName);
}),
pretifyError('i18n:schemas.updateScriptsFailed')); pretifyError('i18n:schemas.updateScriptsFailed'));
} }
@ -447,9 +531,6 @@ export class SchemasService {
map(({ payload }) => { map(({ payload }) => {
return parseSchema(payload.body); return parseSchema(payload.body);
}), }),
tap(() => {
this.analytics.trackEvent('Schema', 'RulesConfigured', appName);
}),
pretifyError('i18n:schemas.updateRulesFailed')); pretifyError('i18n:schemas.updateRulesFailed'));
} }
@ -462,9 +543,6 @@ export class SchemasService {
map(({ payload }) => { map(({ payload }) => {
return parseSchema(payload.body); return parseSchema(payload.body);
}), }),
tap(() => {
this.analytics.trackEvent('Schema', 'Updated', appName);
}),
pretifyError('i18n:schemas.synchronizeFailed')); pretifyError('i18n:schemas.synchronizeFailed'));
} }
@ -477,9 +555,6 @@ export class SchemasService {
map(({ payload }) => { map(({ payload }) => {
return parseSchema(payload.body); return parseSchema(payload.body);
}), }),
tap(() => {
this.analytics.trackEvent('Schema', 'Updated', appName);
}),
pretifyError('i18n:schemas.updateFailed')); pretifyError('i18n:schemas.updateFailed'));
} }
@ -492,9 +567,6 @@ export class SchemasService {
map(({ payload }) => { map(({ payload }) => {
return parseSchema(payload.body); return parseSchema(payload.body);
}), }),
tap(() => {
this.analytics.trackEvent('Schema', 'CategoryChanged', appName);
}),
pretifyError('i18n:schemas.changeCategoryFailed')); pretifyError('i18n:schemas.changeCategoryFailed'));
} }
@ -507,9 +579,6 @@ export class SchemasService {
map(({ payload }) => { map(({ payload }) => {
return parseSchema(payload.body); return parseSchema(payload.body);
}), }),
tap(() => {
this.analytics.trackEvent('Schema', 'PreviewUrlsConfigured', appName);
}),
pretifyError('i18n:schemas.updatePreviewUrlsFailed')); pretifyError('i18n:schemas.updatePreviewUrlsFailed'));
} }
@ -522,9 +591,6 @@ export class SchemasService {
map(({ payload }) => { map(({ payload }) => {
return parseSchema(payload.body); return parseSchema(payload.body);
}), }),
tap(() => {
this.analytics.trackEvent('Schema', 'Published', appName);
}),
pretifyError('i18n:schemas.publishFailed')); pretifyError('i18n:schemas.publishFailed'));
} }
@ -537,9 +603,6 @@ export class SchemasService {
map(({ payload }) => { map(({ payload }) => {
return parseSchema(payload.body); return parseSchema(payload.body);
}), }),
tap(() => {
this.analytics.trackEvent('Schema', 'Unpublished', appName);
}),
pretifyError('i18n:schemas.unpublishFailed')); pretifyError('i18n:schemas.unpublishFailed'));
} }
@ -552,9 +615,6 @@ export class SchemasService {
map(({ payload }) => { map(({ payload }) => {
return parseSchema(payload.body); return parseSchema(payload.body);
}), }),
tap(() => {
this.analytics.trackEvent('Schema', 'FieldCreated', appName);
}),
pretifyError('i18n:schemas.addFieldFailed')); pretifyError('i18n:schemas.addFieldFailed'));
} }
@ -567,9 +627,6 @@ export class SchemasService {
map(({ payload }) => { map(({ payload }) => {
return parseSchema(payload.body); return parseSchema(payload.body);
}), }),
tap(() => {
this.analytics.trackEvent('Schema', 'UIFieldsConfigured', appName);
}),
pretifyError('i18n:schemas.updateUIFieldsFailed')); pretifyError('i18n:schemas.updateUIFieldsFailed'));
} }
@ -582,9 +639,6 @@ export class SchemasService {
map(({ payload }) => { map(({ payload }) => {
return parseSchema(payload.body); return parseSchema(payload.body);
}), }),
tap(() => {
this.analytics.trackEvent('Schema', 'FieldsReordered', appName);
}),
pretifyError('i18n:schemas.reorderFieldsFailed')); pretifyError('i18n:schemas.reorderFieldsFailed'));
} }
@ -597,9 +651,6 @@ export class SchemasService {
map(({ payload }) => { map(({ payload }) => {
return parseSchema(payload.body); return parseSchema(payload.body);
}), }),
tap(() => {
this.analytics.trackEvent('Schema', 'FieldUpdated', appName);
}),
pretifyError('i18n:schemas.updateFieldFailed')); pretifyError('i18n:schemas.updateFieldFailed'));
} }
@ -612,9 +663,6 @@ export class SchemasService {
map(({ payload }) => { map(({ payload }) => {
return parseSchema(payload.body); return parseSchema(payload.body);
}), }),
tap(() => {
this.analytics.trackEvent('Schema', 'FieldLocked', appName);
}),
pretifyError('i18n:schemas.lockFieldFailed')); pretifyError('i18n:schemas.lockFieldFailed'));
} }
@ -627,9 +675,6 @@ export class SchemasService {
map(({ payload }) => { map(({ payload }) => {
return parseSchema(payload.body); return parseSchema(payload.body);
}), }),
tap(() => {
this.analytics.trackEvent('Schema', 'FieldEnabled', appName);
}),
pretifyError('i18n:schemas.enableFieldFailed')); pretifyError('i18n:schemas.enableFieldFailed'));
} }
@ -642,9 +687,6 @@ export class SchemasService {
map(({ payload }) => { map(({ payload }) => {
return parseSchema(payload.body); return parseSchema(payload.body);
}), }),
tap(() => {
this.analytics.trackEvent('Schema', 'FieldDisabled', appName);
}),
pretifyError('i18n:schemas.disableFieldFailed')); pretifyError('i18n:schemas.disableFieldFailed'));
} }
@ -657,9 +699,6 @@ export class SchemasService {
map(({ payload }) => { map(({ payload }) => {
return parseSchema(payload.body); return parseSchema(payload.body);
}), }),
tap(() => {
this.analytics.trackEvent('Schema', 'FieldShown', appName);
}),
pretifyError('i18n:schemas.showFieldFailed')); pretifyError('i18n:schemas.showFieldFailed'));
} }
@ -672,9 +711,6 @@ export class SchemasService {
map(({ payload }) => { map(({ payload }) => {
return parseSchema(payload.body); return parseSchema(payload.body);
}), }),
tap(() => {
this.analytics.trackEvent('Schema', 'FieldHidden', appName);
}),
pretifyError('i18n:schemas.hideFieldFailed')); pretifyError('i18n:schemas.hideFieldFailed'));
} }
@ -687,9 +723,6 @@ export class SchemasService {
map(({ payload }) => { map(({ payload }) => {
return parseSchema(payload.body); return parseSchema(payload.body);
}), }),
tap(() => {
this.analytics.trackEvent('Schema', 'FieldDeleted', appName);
}),
pretifyError('i18n:schemas.deleteFieldFailed')); pretifyError('i18n:schemas.deleteFieldFailed'));
} }
@ -699,9 +732,6 @@ export class SchemasService {
const url = this.apiUrl.buildUrl(link.href); const url = this.apiUrl.buildUrl(link.href);
return HTTP.requestVersioned(this.http, link.method, url, version).pipe( return HTTP.requestVersioned(this.http, link.method, url, version).pipe(
tap(() => {
this.analytics.trackEvent('Schema', 'Deleted', appName);
}),
pretifyError('i18n:schemas.deleteFailed')); pretifyError('i18n:schemas.deleteFailed'));
} }
@ -719,11 +749,12 @@ export class SchemasService {
} }
function parseSchemas(response: { items: any[] } & Resource) { function parseSchemas(response: { items: any[] } & Resource) {
const items = response.items.map(parseSchema); const { items: list, _links } = response;
const items = list.map(parseSchema);
const _links = response._links; const canCreate = hasAnyLink(_links, 'create');
return { items, _links, canCreate: hasAnyLink(_links, 'create') }; return { items, canCreate };
} }
function parseSchema(response: any) { function parseSchema(response: any) {

18
frontend/src/app/shared/services/schemas.spec.ts

@ -5,7 +5,7 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { createProperties, MetaFields, SchemaPropertiesDto } from '@app/shared/internal'; import { createProperties, META_FIELDS, SchemaPropertiesDto } from '@app/shared/internal';
import { TestValues } from './../state/_test-helpers'; import { TestValues } from './../state/_test-helpers';
const { const {
@ -58,10 +58,10 @@ describe('SchemaDto', () => {
const schema = createSchema({ properties: new SchemaPropertiesDto(''), fields: [field1, field2, field3] }); const schema = createSchema({ properties: new SchemaPropertiesDto(''), fields: [field1, field2, field3] });
expect(schema.defaultListFields.map(x => x.name)).toEqual([ expect(schema.defaultListFields.map(x => x.name)).toEqual([
MetaFields.lastModifiedByAvatar.name, META_FIELDS.lastModifiedByAvatar.name,
field1.name, field1.name,
MetaFields.statusColor.name, META_FIELDS.statusColor.name,
MetaFields.lastModified.name, META_FIELDS.lastModified.name,
]); ]);
}); });
@ -69,10 +69,10 @@ describe('SchemaDto', () => {
const schema = createSchema({ properties: new SchemaPropertiesDto() }); const schema = createSchema({ properties: new SchemaPropertiesDto() });
expect(schema.defaultListFields.map(x => x.name)).toEqual([ expect(schema.defaultListFields.map(x => x.name)).toEqual([
MetaFields.lastModifiedByAvatar.name, META_FIELDS.lastModifiedByAvatar.name,
MetaFields.empty.name, META_FIELDS.empty.name,
MetaFields.statusColor.name, META_FIELDS.statusColor.name,
MetaFields.lastModified.name, META_FIELDS.lastModified.name,
]); ]);
}); });
@ -88,7 +88,7 @@ describe('SchemaDto', () => {
const schema = createSchema({ properties: new SchemaPropertiesDto() }); const schema = createSchema({ properties: new SchemaPropertiesDto() });
expect(schema.defaultReferenceFields.map(x => x.name)).toEqual([ expect(schema.defaultReferenceFields.map(x => x.name)).toEqual([
MetaFields.empty.name, META_FIELDS.empty.name,
]); ]);
}); });
}); });

18
frontend/src/app/shared/services/search.service.ts

@ -46,13 +46,15 @@ export class SearchService {
} }
} }
function parseResults(body: any[]) { function parseResults(response: any[]) {
const results = body.map(item => new SearchResultDto( return response.map(parseResult);
item._links, }
item.name,
item.type, function parseResult(response: any) {
item.label)); return new SearchResultDto(
response._links,
return results; response.name,
response.type,
response.label);
} }

17
frontend/src/app/shared/services/stock-photo.service.ts

@ -37,12 +37,15 @@ export class StockPhotoService {
catchError(() => of([]))); catchError(() => of([])));
} }
} }
function parseImages(body: any[]) { function parseImages(response: any[]) {
return body.map(x => new StockPhotoDto( return response.map(parseImage);
x.url, }
x.thumbUrl,
x.user, function parseImage(response: any) {
x.userProfileUrl, return new StockPhotoDto(
)); response.url,
response.thumbUrl,
response.user,
response.userProfileUrl);
} }

7
frontend/src/app/shared/services/templates.service.spec.ts

@ -46,11 +46,12 @@ describe('TemplatesService', () => {
], ],
}); });
expect(templates!).toEqual( expect(templates!).toEqual({
new TemplatesDto(2, [ items: [
createTemplate(1), createTemplate(1),
createTemplate(2), createTemplate(2),
], {})); ],
});
})); }));
it('should make get request to get template', it('should make get request to get template',

32
frontend/src/app/shared/services/templates.service.ts

@ -9,14 +9,11 @@ import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { ApiUrlConfig, pretifyError, Resource, ResourceLinks, ResultSet } from '@app/framework'; import { ApiUrlConfig, pretifyError, Resource, ResourceLinks } from '@app/framework';
export class TemplatesDto extends ResultSet<TemplateDto> {
}
export class TemplateDto { export class TemplateDto {
public readonly _links: ResourceLinks; public readonly _links: ResourceLinks;
constructor(links: ResourceLinks, constructor(links: ResourceLinks,
public readonly name: string, public readonly name: string,
public readonly title: string, public readonly title: string,
@ -37,6 +34,11 @@ export class TemplateDetailsDto {
} }
} }
export type TemplatesDto = Readonly<{
// The list of templates.
items: ReadonlyArray<TemplateDto>;
}>;
@Injectable() @Injectable()
export class TemplatesService { export class TemplatesService {
constructor( constructor(
@ -68,15 +70,19 @@ export class TemplatesService {
} }
} }
function parseTemplates(response: { items: any[] } & Resource) { function parseTemplates(response: { items: any[] } & Resource): TemplatesDto {
const items = response.items.map(item => const { items: list } = response;
new TemplateDto(item._links, const items = list.map(parseTemplate);
item.name,
item.title, return { items };
item.description, }
item.isStarter));
return new TemplatesDto(items.length, items, response._links); function parseTemplate(response: any & Resource) {
return new TemplateDto(response._links,
response.name,
response.title,
response.description,
response.isStarter);
} }
function parseTemplateDetails(response: any & Resource) { function parseTemplateDetails(response: any & Resource) {

12
frontend/src/app/shared/services/translations.service.ts

@ -19,8 +19,16 @@ export class TranslationDto {
} }
} }
export type TranslateDto = export type TranslateDto = Readonly<{
Readonly<{ text: string; sourceLanguage: string; targetLanguage: string }>; // The text to translate.
text: string;
// The source language.
sourceLanguage: string;
// The target language.
targetLanguage: string;
}>;
@Injectable() @Injectable()
export class TranslationsService { export class TranslationsService {

6
frontend/src/app/shared/services/ui.service.ts

@ -11,8 +11,10 @@ import { Observable, of } from 'rxjs';
import { catchError } from 'rxjs/operators'; import { catchError } from 'rxjs/operators';
import { ApiUrlConfig } from '@app/framework'; import { ApiUrlConfig } from '@app/framework';
export type UISettingsDto = export type UISettingsDto = Readonly<{
Readonly<{ canCreateApps: boolean }>; // True, if the user has the permissions to create a new app.
canCreateApps?: boolean;
}>;
@Injectable() @Injectable()
export class UIService { export class UIService {

12
frontend/src/app/shared/services/users.service.spec.ts

@ -7,7 +7,7 @@
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { inject, TestBed } from '@angular/core/testing'; import { inject, TestBed } from '@angular/core/testing';
import { ApiUrlConfig, ResourcesDto, UserDto, UsersService } from '@app/shared/internal'; import { ApiUrlConfig, Resource, UserDto, UsersService } from '@app/shared/internal';
describe('UsersService', () => { describe('UsersService', () => {
beforeEach(() => { beforeEach(() => {
@ -108,7 +108,7 @@ describe('UsersService', () => {
it('should make get request to get resources', it('should make get request to get resources',
inject([UsersService, HttpTestingController], (usersService: UsersService, httpMock: HttpTestingController) => { inject([UsersService, HttpTestingController], (usersService: UsersService, httpMock: HttpTestingController) => {
let resources: ResourcesDto; let resources: Resource;
usersService.getResources().subscribe(result => { usersService.getResources().subscribe(result => {
resources = result; resources = result;
@ -125,10 +125,10 @@ describe('UsersService', () => {
}, },
}); });
const expected = new ResourcesDto({ expect(resources!).toEqual({
schemas: { method: 'GET', href: '/api/schemas' }, _links: {
schemas: { method: 'GET', href: '/api/schemas' },
},
}); });
expect(resources!).toEqual(expected);
})); }));
}); });

17
frontend/src/app/shared/services/users.service.ts

@ -9,7 +9,7 @@ import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { ApiUrlConfig, pretifyError, ResourceLinks } from '@app/framework'; import { ApiUrlConfig, pretifyError, Resource } from '@app/framework';
export class UserDto { export class UserDto {
constructor( constructor(
@ -19,14 +19,6 @@ export class UserDto {
} }
} }
export class ResourcesDto {
public readonly _links: ResourceLinks;
constructor(links: ResourceLinks) {
this._links = links;
}
}
@Injectable() @Injectable()
export class UsersService { export class UsersService {
constructor( constructor(
@ -55,13 +47,10 @@ export class UsersService {
pretifyError('i18n:users.loadUserFailed')); pretifyError('i18n:users.loadUserFailed'));
} }
public getResources(): Observable<ResourcesDto> { public getResources(): Observable<Resource> {
const url = this.apiUrl.buildUrl('api'); const url = this.apiUrl.buildUrl('api');
return this.http.get<{ _links: {} }>(url).pipe( return this.http.get<Resource>(url).pipe(
map(({ _links }) => {
return new ResourcesDto(_links);
}),
pretifyError('i18n:users.loadUserFailed')); pretifyError('i18n:users.loadUserFailed'));
} }
} }

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

@ -9,7 +9,7 @@
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { inject, TestBed } from '@angular/core/testing'; import { inject, TestBed } from '@angular/core/testing';
import { AnalyticsService, ApiUrlConfig, Resource, Version, WorkflowDto, WorkflowsDto, WorkflowsPayload, WorkflowsService } from '@app/shared/internal'; import { ApiUrlConfig, Resource, Version, WorkflowDto, WorkflowsDto, WorkflowsPayload, WorkflowsService } from '@app/shared/internal';
describe('WorkflowsService', () => { describe('WorkflowsService', () => {
const version = new Version('1'); const version = new Version('1');
@ -22,7 +22,6 @@ describe('WorkflowsService', () => {
providers: [ providers: [
WorkflowsService, WorkflowsService,
{ provide: ApiUrlConfig, useValue: new ApiUrlConfig('http://service/p/') }, { provide: ApiUrlConfig, useValue: new ApiUrlConfig('http://service/p/') },
{ provide: AnalyticsService, useValue: new AnalyticsService() },
], ],
}); });
}); });
@ -178,14 +177,11 @@ describe('WorkflowsService', () => {
export function createWorkflows(...names: ReadonlyArray<string>): WorkflowsPayload { export function createWorkflows(...names: ReadonlyArray<string>): WorkflowsPayload {
return { return {
items: names.map(createWorkflow),
errors: [ errors: [
'Error1', 'Error1',
'Error2', 'Error2',
], ],
items: names.map(createWorkflow),
_links: {
create: { method: 'POST', href: '/workflows' },
},
canCreate: true, canCreate: true,
}; };
} }
@ -510,7 +506,7 @@ describe('Workflow', () => {
it('should rename workflow', () => { it('should rename workflow', () => {
const workflow = const workflow =
new WorkflowDto({}, 'id') new WorkflowDto({}, 'id')
.rename('name'); .changeName('name');
expect(workflow.serialize()).toEqual({ name: 'name', schemaIds: [], steps: {}, initial: null }); expect(workflow.serialize()).toEqual({ name: 'name', schemaIds: [], steps: {}, initial: null });
}); });

124
frontend/src/app/shared/services/workflows.service.ts

@ -8,19 +8,17 @@
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators'; import { ApiUrlConfig, compareStrings, hasAnyLink, HTTP, mapVersioned, pretifyError, Resource, ResourceLinks, StringHelper, Version, Versioned } from '@app/framework';
import { AnalyticsService, ApiUrlConfig, compareStrings, hasAnyLink, HTTP, mapVersioned, Model, pretifyError, Resource, ResourceLinks, StringHelper, Version, Versioned } from '@app/framework';
export class WorkflowDto extends Model<WorkflowDto> { export class WorkflowDto {
public readonly _links: ResourceLinks; public readonly _links: ResourceLinks;
public readonly canUpdate: boolean;
public readonly canDelete: boolean; public readonly canDelete: boolean;
public readonly canUpdate: boolean;
public readonly displayName: string; public readonly displayName: string;
constructor( constructor(links: ResourceLinks,
links: ResourceLinks,
public readonly id: string, public readonly id: string,
public readonly name: string | null = null, public readonly name: string | null = null,
public readonly initial: string | null = null, public readonly initial: string | null = null,
@ -28,22 +26,22 @@ export class WorkflowDto extends Model<WorkflowDto> {
public readonly steps: WorkflowStep[] = [], public readonly steps: WorkflowStep[] = [],
public readonly transitions: WorkflowTransition[] = [], public readonly transitions: WorkflowTransition[] = [],
) { ) {
super();
this.onCloned();
this._links = links; this._links = links;
this.canUpdate = hasAnyLink(links, 'update');
this.canDelete = hasAnyLink(links, 'delete'); this.canDelete = hasAnyLink(links, 'delete');
this.canUpdate = hasAnyLink(links, 'update');
this.displayName = StringHelper.firstNonEmpty(name, 'i18n:workflows.notNamed'); this.displayName = StringHelper.firstNonEmpty(name, 'i18n:workflows.notNamed');
} }
protected onCloned() { protected onCloned() {
this.steps.sort((a, b) => compareStrings(a.name, b.name)); this.steps.sort((a, b) => {
return compareStrings(a.name, b.name);
});
this.transitions.sort((a, b) => compareStrings(a.to, b.to)); this.transitions.sort((a, b) => {
return compareStrings(a.to, b.to);
});
} }
public getOpenSteps(step: WorkflowStep) { public getOpenSteps(step: WorkflowStep) {
@ -116,7 +114,7 @@ export class WorkflowDto extends Model<WorkflowDto> {
return this.with({ schemaIds }); return this.with({ schemaIds });
} }
public rename(name: string) { public changeName(name: string) {
return this.with({ name }); return this.with({ name });
} }
@ -173,7 +171,6 @@ export class WorkflowDto extends Model<WorkflowDto> {
const s = { ...values, transitions: {} }; const s = { ...values, transitions: {} };
for (const transition of this.getTransitions(step)) { for (const transition of this.getTransitions(step)) {
// eslint-disable-next-line @typescript-eslint/naming-convention
const { to, step: _, from: __, ...t } = transition; const { to, step: _, from: __, ...t } = transition;
s.transitions[to] = t; s.transitions[to] = t;
@ -184,38 +181,85 @@ export class WorkflowDto extends Model<WorkflowDto> {
return result; return result;
} }
private with(update: Partial<WorkflowDto>) {
const clone = Object.assign(Object.assign(Object.create(Object.getPrototypeOf(this)), this), update);
clone.onCloned();
return clone;
}
} }
export type WorkflowStepValues = export type WorkflowsDto = Versioned<WorkflowsPayload>;
Readonly<{ color?: string; isLocked?: boolean; validate?: boolean; noUpdate?: boolean; noUpdateExpression?: string; noUpdateRoles?: ReadonlyArray<string> }>;
export type WorkflowStep = export type WorkflowsPayload = Readonly<{
Readonly<{ name: string } & WorkflowStepValues>; // The list of workflows.
items: WorkflowDto[];
export type WorkflowTransitionValues = // The validations errors.
Readonly<{ expression?: string; roles?: string[] }>; errors: string[];
export type WorkflowTransition = // True, if the user has permissions to create a new workflow.
Readonly<{ from: string; to: string } & WorkflowTransitionValues>; canCreate?: boolean;
}>;
export type WorkflowTransitionView = export type WorkflowStepValues = Readonly<{
Readonly<{ step: WorkflowStep } & WorkflowTransition>; // The color of the step.
color?: string;
export type WorkflowsDto = // True, if the step cannot be removed.
Versioned<WorkflowsPayload>; isLocked?: boolean;
export type WorkflowsPayload = // True, if the content should be validated on this step.
Readonly<{ items: WorkflowDto[]; errors: string[]; canCreate: boolean } & Resource>; validate?: boolean;
export type CreateWorkflowDto = // True, when the step has an update restriction.
Readonly<{ name: string }>; noUpdate?: boolean;
// The expression when updates are not allowed.
noUpdateExpression?: string;
// The user roles which cannot update a content.
noUpdateRoles?: ReadonlyArray<string>;
}>;
export type WorkflowStep = Readonly<{
// The name of the workflow.
name: string;
} & WorkflowStepValues>;
export type WorkflowTransitionValues = Readonly<{
// The expression when a transition is possible.
expression?: string;
// The user roles which can transition to this step.
roles?: string[];
}>;
export type WorkflowTransition = Readonly<{
// The source step name.
from: string;
// The target step name.
to: string;
} & WorkflowTransitionValues>;
export type WorkflowTransitionView = Readonly<{
// The actual workflow step.
step: WorkflowStep;
} & WorkflowTransition>;
export type CreateWorkflowDto = Readonly<{
// The name of the workflow.
name: string;
}>;
@Injectable() @Injectable()
export class WorkflowsService { export class WorkflowsService {
constructor( constructor(
private readonly http: HttpClient, private readonly http: HttpClient,
private readonly apiUrl: ApiUrlConfig, private readonly apiUrl: ApiUrlConfig,
private readonly analytics: AnalyticsService,
) { ) {
} }
@ -236,9 +280,6 @@ export class WorkflowsService {
mapVersioned(({ body }) => { mapVersioned(({ body }) => {
return parseWorkflows(body); return parseWorkflows(body);
}), }),
tap(() => {
this.analytics.trackEvent('Workflow', 'Created', appName);
}),
pretifyError('i18n:workflows.createFailed')); pretifyError('i18n:workflows.createFailed'));
} }
@ -251,9 +292,6 @@ export class WorkflowsService {
mapVersioned(({ body }) => { mapVersioned(({ body }) => {
return parseWorkflows(body); return parseWorkflows(body);
}), }),
tap(() => {
this.analytics.trackEvent('Workflow', 'Updated', appName);
}),
pretifyError('i18n:workflows.updateFailed')); pretifyError('i18n:workflows.updateFailed'));
} }
@ -266,19 +304,17 @@ export class WorkflowsService {
mapVersioned(({ body }) => { mapVersioned(({ body }) => {
return parseWorkflows(body); return parseWorkflows(body);
}), }),
tap(() => {
this.analytics.trackEvent('Workflow', 'Deleted', appName);
}),
pretifyError('i18n:workflows.deleteFailed')); pretifyError('i18n:workflows.deleteFailed'));
} }
} }
function parseWorkflows(response: { items: any[]; errors: string[] } & Resource) { function parseWorkflows(response: { items: any[]; errors: string[] } & Resource) {
const items = response.items.map(parseWorkflow); const { items: list, errors, _links } = response;
const items = list.map(parseWorkflow);
const { errors, _links } = response; const canCreate = hasAnyLink(_links, 'create');
return { errors, items, _links, canCreate: hasAnyLink(_links, 'create') }; return { items, errors, canCreate };
} }
function parseWorkflow(workflow: any) { function parseWorkflow(workflow: any) {

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

@ -104,7 +104,7 @@ export class AssetUploaderState extends State<Snapshot> {
} else { } else {
return event; return event;
} }
}), shareReplay()); }), shareReplay());
stream.subscribe({ stream.subscribe({
next: event => { next: event => {

40
frontend/src/app/shared/state/assets.state.spec.ts

@ -9,7 +9,7 @@ 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 { ErrorDto } from '@app/framework'; import { ErrorDto } from '@app/framework';
import { AssetFoldersDto, AssetsDto, AssetsService, AssetsState, DialogService, MathHelper, versioned } from '@app/shared/internal'; import { AssetsService, AssetsState, DialogService, MathHelper, versioned } from '@app/shared/internal';
import { createAsset, createAssetFolder } from './../services/assets.service.spec'; import { createAsset, createAssetFolder } from './../services/assets.service.spec';
import { TestValues } from './_test-helpers'; import { TestValues } from './_test-helpers';
@ -49,12 +49,12 @@ describe('AssetsState', () => {
describe('Loading', () => { describe('Loading', () => {
beforeEach(() => { beforeEach(() => {
assetsService.setup(x => x.getAssetFolders(app, MathHelper.EMPTY_GUID, 'PathAndItems')) assetsService.setup(x => x.getAssetFolders(app, MathHelper.EMPTY_GUID, 'PathAndItems'))
.returns(() => of(new AssetFoldersDto(2, [assetFolder1, assetFolder2], []))).verifiable(Times.atLeastOnce()); .returns(() => of({ items: [assetFolder1, assetFolder2], path: [] })).verifiable(Times.atLeastOnce());
}); });
it('should load assets', () => { it('should load assets', () => {
assetsService.setup(x => x.getAssets(app, { take: 30, skip: 0, parentId: MathHelper.EMPTY_GUID, noSlowTotal: true })) assetsService.setup(x => x.getAssets(app, { take: 30, skip: 0, parentId: MathHelper.EMPTY_GUID, noSlowTotal: true }))
.returns(() => of(new AssetsDto(200, [asset1, asset2]))).verifiable(); .returns(() => of({ items: [asset1, asset2], total: 200 })).verifiable();
assetsState.load().subscribe(); assetsState.load().subscribe();
@ -68,7 +68,7 @@ describe('AssetsState', () => {
it('should show notification on load if reload is true', () => { it('should show notification on load if reload is true', () => {
assetsService.setup(x => x.getAssets(app, { take: 30, skip: 0, parentId: MathHelper.EMPTY_GUID, noSlowTotal: true })) assetsService.setup(x => x.getAssets(app, { take: 30, skip: 0, parentId: MathHelper.EMPTY_GUID, noSlowTotal: true }))
.returns(() => of(new AssetsDto(200, [asset1, asset2]))).verifiable(); .returns(() => of({ items: [asset1, asset2], total: 200 })).verifiable();
assetsState.load(true).subscribe(); assetsState.load(true).subscribe();
@ -79,7 +79,7 @@ describe('AssetsState', () => {
it('should load with total', () => { it('should load with total', () => {
assetsService.setup(x => x.getAssets(app, { take: 30, skip: 0, parentId: MathHelper.EMPTY_GUID, noSlowTotal: false })) assetsService.setup(x => x.getAssets(app, { take: 30, skip: 0, parentId: MathHelper.EMPTY_GUID, noSlowTotal: false }))
.returns(() => of(new AssetsDto(200, [asset1, asset2]))).verifiable(); .returns(() => of({ items: [asset1, asset2], total: 200 })).verifiable();
assetsState.load(true, false).subscribe(); assetsState.load(true, false).subscribe();
@ -90,10 +90,10 @@ describe('AssetsState', () => {
it('should load without tags if tag untoggled', () => { it('should load without tags if tag untoggled', () => {
assetsService.setup(x => x.getAssets(app, { take: 30, skip: 0, tags: ['tag1'], noSlowTotal: true })) assetsService.setup(x => x.getAssets(app, { take: 30, skip: 0, tags: ['tag1'], noSlowTotal: true }))
.returns(() => of(new AssetsDto(0, []))).verifiable(); .returns(() => of({ items: [], total: 0 })).verifiable();
assetsService.setup(x => x.getAssets(app, { take: 30, skip: 0, parentId: MathHelper.EMPTY_GUID, noSlowTotal: true })) assetsService.setup(x => x.getAssets(app, { take: 30, skip: 0, parentId: MathHelper.EMPTY_GUID, noSlowTotal: true }))
.returns(() => of(new AssetsDto(0, []))).verifiable(); .returns(() => of({ items: [], total: 0 })).verifiable();
assetsState.toggleTag('tag1').subscribe(); assetsState.toggleTag('tag1').subscribe();
assetsState.toggleTag('tag1').subscribe(); assetsState.toggleTag('tag1').subscribe();
@ -103,7 +103,7 @@ describe('AssetsState', () => {
it('should load without tags if tags reset', () => { it('should load without tags if tags reset', () => {
assetsService.setup(x => x.getAssets(app, { take: 30, skip: 0, parentId: MathHelper.EMPTY_GUID, noSlowTotal: true })) assetsService.setup(x => x.getAssets(app, { take: 30, skip: 0, parentId: MathHelper.EMPTY_GUID, noSlowTotal: true }))
.returns(() => of(new AssetsDto(0, []))).verifiable(); .returns(() => of({ items: [], total: 0 })).verifiable();
assetsState.resetTags().subscribe(); assetsState.resetTags().subscribe();
@ -112,7 +112,7 @@ describe('AssetsState', () => {
it('should load with new pagination if paging', () => { it('should load with new pagination if paging', () => {
assetsService.setup(x => x.getAssets(app, { take: 30, skip: 30, parentId: MathHelper.EMPTY_GUID, noSlowTotal: true })) assetsService.setup(x => x.getAssets(app, { take: 30, skip: 30, parentId: MathHelper.EMPTY_GUID, noSlowTotal: true }))
.returns(() => of(new AssetsDto(200, []))).verifiable(); .returns(() => of({ items: [], total: 200 })).verifiable();
assetsState.page({ page: 1, pageSize: 30 }).subscribe(); assetsState.page({ page: 1, pageSize: 30 }).subscribe();
@ -121,10 +121,10 @@ describe('AssetsState', () => {
it('should skip page size if loaded before', () => { it('should skip page size if loaded before', () => {
assetsService.setup(x => x.getAssets(app, { take: 30, skip: 0, parentId: MathHelper.EMPTY_GUID, noSlowTotal: true })) assetsService.setup(x => x.getAssets(app, { take: 30, skip: 0, parentId: MathHelper.EMPTY_GUID, noSlowTotal: true }))
.returns(() => of(new AssetsDto(200, [asset1, asset2]))).verifiable(); .returns(() => of({ items: [asset1, asset2], total: 200 })).verifiable();
assetsService.setup(x => x.getAssets(app, { take: 30, skip: 30, parentId: MathHelper.EMPTY_GUID, noSlowTotal: true, noTotal: true })) assetsService.setup(x => x.getAssets(app, { take: 30, skip: 30, parentId: MathHelper.EMPTY_GUID, noSlowTotal: true, noTotal: true }))
.returns(() => of(new AssetsDto(200, []))).verifiable(); .returns(() => of({ items: [], total: 200 })).verifiable();
assetsState.load().subscribe(); assetsState.load().subscribe();
assetsState.page({ page: 1, pageSize: 30 }).subscribe(); assetsState.page({ page: 1, pageSize: 30 }).subscribe();
@ -136,10 +136,10 @@ describe('AssetsState', () => {
describe('Navigating', () => { describe('Navigating', () => {
it('should load with parent id', () => { it('should load with parent id', () => {
assetsService.setup(x => x.getAssetFolders(app, '123', 'PathAndItems')) assetsService.setup(x => x.getAssetFolders(app, '123', 'PathAndItems'))
.returns(() => of(new AssetFoldersDto(2, [assetFolder1, assetFolder2], []))).verifiable(); .returns(() => of({ items: [assetFolder1, assetFolder2], path: [] })).verifiable();
assetsService.setup(x => x.getAssets(app, { take: 30, skip: 0, parentId: '123', noSlowTotal: true })) assetsService.setup(x => x.getAssets(app, { take: 30, skip: 0, parentId: '123', noSlowTotal: true }))
.returns(() => of(new AssetsDto(200, []))).verifiable(); .returns(() => of({ items: [], total: 200 })).verifiable();
assetsState.navigate('123').subscribe(); assetsState.navigate('123').subscribe();
@ -150,7 +150,7 @@ describe('AssetsState', () => {
describe('Searching', () => { describe('Searching', () => {
it('should load with tags if tag toggled', () => { it('should load with tags if tag toggled', () => {
assetsService.setup(x => x.getAssets(app, { take: 30, skip: 0, tags: ['tag1'], noSlowTotal: true })) assetsService.setup(x => x.getAssets(app, { take: 30, skip: 0, tags: ['tag1'], noSlowTotal: true }))
.returns(() => of(new AssetsDto(0, []))).verifiable(); .returns(() => of({ items: [], total: 0 })).verifiable();
assetsState.toggleTag('tag1').subscribe(); assetsState.toggleTag('tag1').subscribe();
@ -159,7 +159,7 @@ describe('AssetsState', () => {
it('should load with tags if tags selected', () => { it('should load with tags if tags selected', () => {
assetsService.setup(x => x.getAssets(app, { take: 30, skip: 0, tags: ['tag1', 'tag2'], noSlowTotal: true })) assetsService.setup(x => x.getAssets(app, { take: 30, skip: 0, tags: ['tag1', 'tag2'], noSlowTotal: true }))
.returns(() => of(new AssetsDto(0, []))).verifiable(); .returns(() => of({ items: [], total: 0 })).verifiable();
assetsState.selectTags(['tag1', 'tag2']).subscribe(); assetsState.selectTags(['tag1', 'tag2']).subscribe();
@ -170,7 +170,7 @@ describe('AssetsState', () => {
const query = { fullText: 'my-query' }; const query = { fullText: 'my-query' };
assetsService.setup(x => x.getAssets(app, { take: 30, skip: 0, query, noSlowTotal: true })) assetsService.setup(x => x.getAssets(app, { take: 30, skip: 0, query, noSlowTotal: true }))
.returns(() => of(new AssetsDto(0, []))).verifiable(); .returns(() => of({ items: [], total: 0 })).verifiable();
assetsState.search(query).subscribe(); assetsState.search(query).subscribe();
@ -181,7 +181,7 @@ describe('AssetsState', () => {
const query = { fullText: 'my-query' }; const query = { fullText: 'my-query' };
assetsService.setup(x => x.getAssets(app, { take: 30, skip: 0, query, noSlowTotal: true })) assetsService.setup(x => x.getAssets(app, { take: 30, skip: 0, query, noSlowTotal: true }))
.returns(() => of(new AssetsDto(0, []))).verifiable(); .returns(() => of({ items: [], total: 0 })).verifiable();
assetsState.next({ ref: '1' }); assetsState.next({ ref: '1' });
assetsState.search(query).subscribe(); assetsState.search(query).subscribe();
@ -194,13 +194,13 @@ describe('AssetsState', () => {
describe('Updates', () => { describe('Updates', () => {
beforeEach(() => { beforeEach(() => {
assetsService.setup(x => x.getAssetFolders(app, MathHelper.EMPTY_GUID, 'PathAndItems')) assetsService.setup(x => x.getAssetFolders(app, MathHelper.EMPTY_GUID, 'PathAndItems'))
.returns(() => of(new AssetFoldersDto(2, [assetFolder1, assetFolder2], []))); .returns(() => of({ items: [assetFolder1, assetFolder2], path: [] }));
assetsService.setup(x => x.getAssets(app, { take: 30, skip: 0, parentId: MathHelper.EMPTY_GUID, noSlowTotal: true })) assetsService.setup(x => x.getAssets(app, { take: 30, skip: 0, parentId: MathHelper.EMPTY_GUID, noSlowTotal: true }))
.returns(() => of(new AssetsDto(200, [asset1, asset2]))).verifiable(); .returns(() => of({ items: [asset1, asset2], total: 200 })).verifiable();
assetsService.setup(x => x.getAssets(app, { take: 2, skip: 0, parentId: MathHelper.EMPTY_GUID, noSlowTotal: true })) assetsService.setup(x => x.getAssets(app, { take: 2, skip: 0, parentId: MathHelper.EMPTY_GUID, noSlowTotal: true }))
.returns(() => of(new AssetsDto(200, [asset1, asset2]))); .returns(() => of({ items: [asset1, asset2], total: 200 }));
assetsState.load(true).subscribe(); assetsState.load(true).subscribe();
}); });

8
frontend/src/app/shared/state/backups.state.spec.ts

@ -8,7 +8,7 @@
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 { BackupsDto, BackupsService, BackupsState, DialogService } from '@app/shared/internal'; import { BackupsService, BackupsState, DialogService } from '@app/shared/internal';
import { createBackup } from './../services/backups.service.spec'; import { createBackup } from './../services/backups.service.spec';
import { TestValues } from './_test-helpers'; import { TestValues } from './_test-helpers';
@ -39,7 +39,7 @@ describe('BackupsState', () => {
describe('Loading', () => { describe('Loading', () => {
it('should load backups', () => { it('should load backups', () => {
backupsService.setup(x => x.getBackups(app)) backupsService.setup(x => x.getBackups(app))
.returns(() => of(new BackupsDto(2, [backup1, backup2], {}))).verifiable(); .returns(() => of({ items: [backup1, backup2] } as any)).verifiable();
backupsState.load().subscribe(); backupsState.load().subscribe();
@ -61,7 +61,7 @@ describe('BackupsState', () => {
it('should show notification on load if reload is true', () => { it('should show notification on load if reload is true', () => {
backupsService.setup(x => x.getBackups(app)) backupsService.setup(x => x.getBackups(app))
.returns(() => of(new BackupsDto(2, [backup1, backup2], {}))).verifiable(); .returns(() => of({ items: [backup1, backup2] } as any)).verifiable();
backupsState.load(true, false).subscribe(); backupsState.load(true, false).subscribe();
@ -96,7 +96,7 @@ describe('BackupsState', () => {
describe('Updates', () => { describe('Updates', () => {
beforeEach(() => { beforeEach(() => {
backupsService.setup(x => x.getBackups(app)) backupsService.setup(x => x.getBackups(app))
.returns(() => of(new BackupsDto(2, [backup1, backup2], {}))).verifiable(); .returns(() => of({ items: [backup1, backup2] } as any)).verifiable();
backupsState.load().subscribe(); backupsState.load().subscribe();
}); });

11
frontend/src/app/shared/state/comments.state.ts

@ -99,7 +99,7 @@ export class CommentsState extends State<Snapshot> {
public update(comment: CommentDto, text: string, now?: DateTime): Observable<CommentDto> { public update(comment: CommentDto, text: string, now?: DateTime): Observable<CommentDto> {
return this.commentsService.putComment(this.commentsUrl, comment.id, { text }).pipe( return this.commentsService.putComment(this.commentsUrl, comment.id, { text }).pipe(
map(() => update(comment, text, now || DateTime.now())), map(() => update(comment, text, now)),
tap(updated => { tap(updated => {
this.next(s => { this.next(s => {
const comments = s.comments.replacedBy('id', updated); const comments = s.comments.replacedBy('id', updated);
@ -115,5 +115,10 @@ export class CommentsState extends State<Snapshot> {
} }
} }
const update = (comment: CommentDto, text: string, time: DateTime) => const update = (comment: CommentDto, text: string, time?: DateTime) =>
comment.with({ text, time }); new CommentDto(
comment.id,
time || DateTime.now(),
text,
comment.url,
comment.user);

2
frontend/src/app/shared/state/contents.forms-helpers.ts

@ -73,7 +73,7 @@ export function fieldTranslationStatus(data: any) {
for (const [key, value] of Object.entries(data)) { for (const [key, value] of Object.entries(data)) {
result[key] = isValidValue(value); result[key] = isValidValue(value);
} }
return result; return result;
} }

16
frontend/src/app/shared/state/contents.forms.spec.ts

@ -109,14 +109,14 @@ describe('TranslationStatus', () => {
field1: { field1: {
en: 'en', en: 'en',
de: 'de', de: 'de',
}, },
field2: { field2: {
en: 'en', en: 'en',
de: 'de', de: 'de',
}, },
field3: { field3: {
en: 'en', en: 'en',
}, },
}; };
const result = contentTranslationStatus(data, schema, languages as any); const result = contentTranslationStatus(data, schema, languages as any);
@ -145,23 +145,23 @@ describe('TranslationStatus', () => {
field1: { field1: {
en: 'en', en: 'en',
de: 'de', de: 'de',
}, },
field2: { field2: {
en: 'en', en: 'en',
de: 'de', de: 'de',
}, },
field3: { field3: {
en: 'en', en: 'en',
}, },
}; };
const data2 = { const data2 = {
field1: { field1: {
de: 'de', de: 'de',
}, },
field3: { field3: {
en: 'en', en: 'en',
}, },
}; };
const result = contentsTranslationStatus([data1, data2], schema, languages as any); const result = contentsTranslationStatus([data1, data2], schema, languages as any);

2
frontend/src/app/shared/state/contents.state.ts

@ -352,7 +352,7 @@ export abstract class ContentsStateBase extends State<Snapshot> {
s.selectedContent : s.selectedContent :
content; content;
} }
return { ...s, contents, selectedContent }; return { ...s, contents, selectedContent };
}); });
}), }),

2
frontend/src/app/shared/state/contributors.state.ts

@ -139,7 +139,7 @@ export class ContributorsState extends State<Snapshot> {
tap(({ version, payload }) => { tap(({ version, payload }) => {
this.replaceContributors(version, payload); this.replaceContributors(version, payload);
}), }),
shareMapSubscribed(this.dialogs, x => x.payload._meta && x.payload._meta['isInvited'] === '1', options)); shareMapSubscribed(this.dialogs, x => x.payload.isInvited, options));
} }
private replaceContributors(version: Version, { canCreate, items, maxContributors }: ContributorsPayload) { private replaceContributors(version: Version, { canCreate, items, maxContributors }: ContributorsPayload) {

20
frontend/src/app/shared/state/resolvers.spec.ts

@ -8,7 +8,7 @@
import { firstValueFrom, of, throwError } from 'rxjs'; import { firstValueFrom, of, throwError } from 'rxjs';
import { IMock, Mock, Times } from 'typemoq'; import { IMock, Mock, Times } from 'typemoq';
import { UIOptions } from '@app/framework'; import { UIOptions } from '@app/framework';
import { ContentsDto, ContentsService } from '../services/contents.service'; import { ContentsService } from '../services/contents.service';
import { createContent } from '../services/contents.service.spec'; import { createContent } from '../services/contents.service.spec';
import { TestValues } from './_test-helpers'; import { TestValues } from './_test-helpers';
import { ResolveContents } from './resolvers'; import { ResolveContents } from './resolvers';
@ -42,7 +42,7 @@ describe('ResolveContents', () => {
const ids = ['id1', 'id2']; const ids = ['id1', 'id2'];
contentsService.setup(x => x.getAllContents(app, { ids })) contentsService.setup(x => x.getAllContents(app, { ids }))
.returns(() => of(new ContentsDto([], 2, [contents[0], contents[1]]))); .returns(() => of({ items: [contents[0], contents[1]] } as any));
return expectAsync(firstValueFrom(contentsResolver.resolveMany(ids))).toBePending(); return expectAsync(firstValueFrom(contentsResolver.resolveMany(ids))).toBePending();
}); });
@ -51,7 +51,7 @@ describe('ResolveContents', () => {
const ids = ['id1', 'id2']; const ids = ['id1', 'id2'];
contentsService.setup(x => x.getAllContents(app, { ids })) contentsService.setup(x => x.getAllContents(app, { ids }))
.returns(() => of(new ContentsDto([], 2, [contents[0], contents[1]]))); .returns(() => of({ items: [contents[0], contents[1]] } as any));
const result = await firstValueFrom(contentsResolver.resolveMany(ids)); const result = await firstValueFrom(contentsResolver.resolveMany(ids));
@ -65,7 +65,7 @@ describe('ResolveContents', () => {
const ids = ['id1', 'id2']; const ids = ['id1', 'id2'];
contentsService.setup(x => x.getAllContents(app, { ids })) contentsService.setup(x => x.getAllContents(app, { ids }))
.returns(() => of(new ContentsDto([], 2, [contents[0]]))); .returns(() => of({ items: [contents[0]] } as any));
const result = await firstValueFrom(contentsResolver.resolveMany(ids)); const result = await firstValueFrom(contentsResolver.resolveMany(ids));
@ -90,7 +90,7 @@ describe('ResolveContents', () => {
const ids = ['id1', 'id2', 'id3']; const ids = ['id1', 'id2', 'id3'];
contentsService.setup(x => x.getAllContents(app, { ids })) contentsService.setup(x => x.getAllContents(app, { ids }))
.returns(() => of(new ContentsDto([], 2, [contents[0], contents[1], contents[2]]))); .returns(() => of({ items: [contents[0], contents[1], contents[2]] } as any));
const result1Promise = firstValueFrom(contentsResolver.resolveMany(ids1)); const result1Promise = firstValueFrom(contentsResolver.resolveMany(ids1));
const result2Promise = firstValueFrom(contentsResolver.resolveMany(ids2)); const result2Promise = firstValueFrom(contentsResolver.resolveMany(ids2));
@ -114,7 +114,7 @@ describe('ResolveContents', () => {
const ids = ['id1', 'id2']; const ids = ['id1', 'id2'];
contentsService.setup(x => x.getAllContents(app, { ids })) contentsService.setup(x => x.getAllContents(app, { ids }))
.returns(() => of(new ContentsDto([], 2, [contents[0], contents[1]]))); .returns(() => of({ items: [contents[0], contents[1]] } as any));
const result1Promise = firstValueFrom(contentsResolver.resolveMany(ids)); const result1Promise = firstValueFrom(contentsResolver.resolveMany(ids));
const result2Promise = firstValueFrom(contentsResolver.resolveMany(ids)); const result2Promise = firstValueFrom(contentsResolver.resolveMany(ids));
@ -138,7 +138,7 @@ describe('ResolveContents', () => {
const ids = ['id1', 'id2']; const ids = ['id1', 'id2'];
contentsService.setup(x => x.getAllContents(app, { ids })) contentsService.setup(x => x.getAllContents(app, { ids }))
.returns(() => of(new ContentsDto([], 2, [contents[0], contents[1]]))); .returns(() => of({ items: [contents[0], contents[1]] } as any));
const result1 = await firstValueFrom(contentsResolver.resolveMany(ids)); const result1 = await firstValueFrom(contentsResolver.resolveMany(ids));
const result2 = await firstValueFrom(contentsResolver.resolveMany(ids)); const result2 = await firstValueFrom(contentsResolver.resolveMany(ids));
@ -160,7 +160,7 @@ describe('ResolveContents', () => {
const schema = 'schema1'; const schema = 'schema1';
contentsService.setup(x => x.getContents(app, schema, { take: 100 })) contentsService.setup(x => x.getContents(app, schema, { take: 100 }))
.returns(() => of(new ContentsDto([], 2, [contents[0]]))); .returns(() => of({ items: [contents[0]] } as any));
const result = await firstValueFrom(contentsResolver.resolveAll('schema1')); const result = await firstValueFrom(contentsResolver.resolveAll('schema1'));
@ -173,7 +173,7 @@ describe('ResolveContents', () => {
const schema = 'schema1'; const schema = 'schema1';
contentsService.setup(x => x.getContents(app, schema, { take: 100 })) contentsService.setup(x => x.getContents(app, schema, { take: 100 }))
.returns(() => of(new ContentsDto([], 2, [contents[0]]))); .returns(() => of({ items: [contents[0]] } as any));
const result1Promise = await firstValueFrom(contentsResolver.resolveAll('schema1')); const result1Promise = await firstValueFrom(contentsResolver.resolveAll('schema1'));
const result2Promise = await firstValueFrom(contentsResolver.resolveAll('schema1')); const result2Promise = await firstValueFrom(contentsResolver.resolveAll('schema1'));
@ -193,7 +193,7 @@ describe('ResolveContents', () => {
const schema = 'schema1'; const schema = 'schema1';
contentsService.setup(x => x.getContents(app, schema, { take: 100 })) contentsService.setup(x => x.getContents(app, schema, { take: 100 }))
.returns(() => of(new ContentsDto([], 2, [contents[0]]))); .returns(() => of({ items: [contents[0]] } as any));
const result1 = await firstValueFrom(contentsResolver.resolveAll('schema1')); const result1 = await firstValueFrom(contentsResolver.resolveAll('schema1'));
const result2 = await firstValueFrom(contentsResolver.resolveAll('schema1')); const result2 = await firstValueFrom(contentsResolver.resolveAll('schema1'));

8
frontend/src/app/shared/state/resolvers.ts

@ -129,8 +129,8 @@ export class ResolveContents extends ResolverBase<ContentDto, ContentsDto> {
return result; return result;
} }
protected createResult(items: ContentDto[]) { protected createResult(items: ContentDto[]): ContentsDto {
return new ContentsDto([], items.length, items); return { items, total: items.length } as any;
} }
protected loadMany(ids: string[]) { protected loadMany(ids: string[]) {
@ -151,8 +151,8 @@ export class ResolveAssets extends ResolverBase<AssetDto, AssetsDto> {
super(); super();
} }
protected createResult(items: AssetDto[]) { protected createResult(items: AssetDto[]): AssetsDto {
return new AssetsDto(items.length, items); return { items, total: items.length } as any;
} }
protected loadMany(ids: string[]) { protected loadMany(ids: string[]) {

10
frontend/src/app/shared/state/rule-events.state.spec.ts

@ -8,7 +8,7 @@
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 { DialogService, RuleEventsDto, RuleEventsState, RulesService } from '@app/shared/internal'; import { DialogService, RuleEventsState, RulesService } from '@app/shared/internal';
import { createRuleEvent } from './../services/rules.service.spec'; import { createRuleEvent } from './../services/rules.service.spec';
import { TestValues } from './_test-helpers'; import { TestValues } from './_test-helpers';
@ -32,7 +32,7 @@ describe('RuleEventsState', () => {
rulesService = Mock.ofType<RulesService>(); rulesService = Mock.ofType<RulesService>();
rulesService.setup(x => x.getEvents(app, 30, 0, undefined)) rulesService.setup(x => x.getEvents(app, 30, 0, undefined))
.returns(() => of(new RuleEventsDto(200, oldRuleEvents))); .returns(() => of({ items: oldRuleEvents, total: 200 } as any));
ruleEventsState = new RuleEventsState(appsState.object, dialogs.object, rulesService.object); ruleEventsState = new RuleEventsState(appsState.object, dialogs.object, rulesService.object);
ruleEventsState.load().subscribe(); ruleEventsState.load().subscribe();
@ -66,7 +66,7 @@ describe('RuleEventsState', () => {
it('should load with new pagination if paging', () => { it('should load with new pagination if paging', () => {
rulesService.setup(x => x.getEvents(app, 30, 30, undefined)) rulesService.setup(x => x.getEvents(app, 30, 30, undefined))
.returns(() => of(new RuleEventsDto(200, []))); .returns(() => of({ items: [], total: 0, _links: {} }));
ruleEventsState.page({ page: 1, pageSize: 30 }).subscribe(); ruleEventsState.page({ page: 1, pageSize: 30 }).subscribe();
@ -78,7 +78,7 @@ describe('RuleEventsState', () => {
it('should load with rule id if filtered', () => { it('should load with rule id if filtered', () => {
rulesService.setup(x => x.getEvents(app, 30, 0, '12')) rulesService.setup(x => x.getEvents(app, 30, 0, '12'))
.returns(() => of(new RuleEventsDto(200, []))); .returns(() => of({ items: [], total: 200, _links: {} }));
ruleEventsState.filterByRule('12').subscribe(); ruleEventsState.filterByRule('12').subscribe();
@ -89,7 +89,7 @@ describe('RuleEventsState', () => {
it('should not load again if rule id has not changed', () => { it('should not load again if rule id has not changed', () => {
rulesService.setup(x => x.getEvents(app, 30, 0, '12')) rulesService.setup(x => x.getEvents(app, 30, 0, '12'))
.returns(() => of(new RuleEventsDto(200, []))); .returns(() => of({ items: [], total: 200, _links: {} }));
ruleEventsState.filterByRule('12').subscribe(); ruleEventsState.filterByRule('12').subscribe();
ruleEventsState.filterByRule('12').subscribe(); ruleEventsState.filterByRule('12').subscribe();

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save