Browse Source

Feature/contributor import (#409)

* Variable naming fixed.

* Tests improved and contributor import dialog.
pull/407/head
Sebastian Stehle 7 years ago
committed by GitHub
parent
commit
42e6b3feab
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 6
      src/Squidex.Domain.Apps.Entities/Apps/Commands/AssignContributor.cs
  2. 44
      src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppContributors.cs
  3. 26
      src/Squidex.Domain.Apps.Entities/Apps/Invitation/InviteUserCommandMiddleware.cs
  4. 8
      src/Squidex.Domain.Apps.Entities/Assets/AssetGrain.cs
  5. 5
      src/Squidex/Areas/Api/Controllers/Apps/Models/AssignContributorDto.cs
  6. 2
      src/Squidex/app/features/schemas/pages/schema/schema-edit-form.component.scss
  7. 1
      src/Squidex/app/features/settings/declarations.ts
  8. 2
      src/Squidex/app/features/settings/module.ts
  9. 10
      src/Squidex/app/features/settings/pages/backups/backups-page.component.html
  10. 27
      src/Squidex/app/features/settings/pages/backups/backups-page.component.scss
  11. 10
      src/Squidex/app/features/settings/pages/contributors/contributors-page.component.html
  12. 15
      src/Squidex/app/features/settings/pages/contributors/contributors-page.component.ts
  13. 43
      src/Squidex/app/features/settings/pages/contributors/import-contributors-dialog.component.html
  14. 10
      src/Squidex/app/features/settings/pages/contributors/import-contributors-dialog.component.scss
  15. 96
      src/Squidex/app/features/settings/pages/contributors/import-contributors-dialog.component.ts
  16. 9
      src/Squidex/app/framework/angular/forms/form-hint.component.ts
  17. 14
      src/Squidex/app/framework/angular/status-icon.component.html
  18. 41
      src/Squidex/app/framework/angular/status-icon.component.scss
  19. 25
      src/Squidex/app/framework/angular/status-icon.component.ts
  20. 7
      src/Squidex/app/framework/declarations.ts
  21. 3
      src/Squidex/app/framework/module.ts
  22. 58
      src/Squidex/app/shared/state/contributors.forms.ts
  23. 4
      src/Squidex/app/shared/state/contributors.state.ts
  24. 6
      tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppContributorsTests.cs
  25. 48
      tests/Squidex.Domain.Apps.Entities.Tests/Apps/Invitation/InviteUserCommandMiddlewareTests.cs
  26. 7
      tests/Squidex.Domain.Apps.Entities.Tests/Schemas/Indexes/SchemasByAppIndexCommandMiddlewareTests.cs
  27. 13
      tests/Squidex.Web.Tests/CommandMiddlewares/ETagCommandMiddlewareTests.cs
  28. 15
      tests/Squidex.Web.Tests/CommandMiddlewares/EnrichWithActorCommandMiddlewareTests.cs
  29. 15
      tests/Squidex.Web.Tests/CommandMiddlewares/EnrichWithAppIdCommandMiddlewareTests.cs
  30. 19
      tests/Squidex.Web.Tests/CommandMiddlewares/EnrichWithSchemaIdCommandMiddlewareTests.cs

6
src/Squidex.Domain.Apps.Entities/Apps/Commands/AssignContributor.cs

@ -13,12 +13,10 @@ namespace Squidex.Domain.Apps.Entities.Apps.Commands
{ {
public string ContributorId { get; set; } public string ContributorId { get; set; }
public string Role { get; set; } = Roles.Developer; public string Role { get; set; } = Roles.Editor;
public bool IsRestore { get; set; } public bool IsRestore { get; set; }
public bool IsInviting { get; set; } public bool Invite { get; set; }
public bool IsCreated { get; set; }
} }
} }

44
src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppContributors.cs

@ -32,35 +32,39 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards
if (string.IsNullOrWhiteSpace(command.ContributorId)) if (string.IsNullOrWhiteSpace(command.ContributorId))
{ {
e(Not.Defined("Contributor id"), nameof(command.ContributorId)); e(Not.Defined("Contributor id"), nameof(command.ContributorId));
return;
} }
else
var user = await users.FindByIdOrEmailAsync(command.ContributorId);
if (user == null)
{ {
throw new DomainObjectNotFoundException(command.ContributorId, "Contributors", typeof(IAppEntity)); var user = await users.FindByIdOrEmailAsync(command.ContributorId);
}
command.ContributorId = user.Id;
if (!command.IsRestore) if (user == null)
{
if (string.Equals(command.ContributorId, command.Actor?.Identifier, StringComparison.OrdinalIgnoreCase))
{ {
throw new DomainForbiddenException("You cannot change your own role."); throw new DomainObjectNotFoundException(command.ContributorId, "Contributors", typeof(IAppEntity));
} }
if (contributors.TryGetValue(command.ContributorId, out var existing)) command.ContributorId = user.Id;
if (!command.IsRestore)
{ {
if (existing == command.Role) if (string.Equals(command.ContributorId, command.Actor?.Identifier, StringComparison.OrdinalIgnoreCase))
{ {
e(Not.New("Contributor", "role"), nameof(command.Role)); throw new DomainForbiddenException("You cannot change your own role.");
}
if (contributors.TryGetValue(command.ContributorId, out var role))
{
if (role == command.Role)
{
e(Not.New("Contributor", "role"), nameof(command.Role));
}
}
else
{
if (plan.MaxContributors > 0 && contributors.Count >= plan.MaxContributors)
{
e("You have reached the maximum number of contributors for your plan.");
}
} }
}
else if (plan.MaxContributors > 0 && contributors.Count >= plan.MaxContributors)
{
e("You have reached the maximum number of contributors for your plan.");
} }
} }
}); });

26
src/Squidex.Domain.Apps.Entities/Apps/Invitation/InviteUserCommandMiddleware.cs

@ -27,24 +27,26 @@ namespace Squidex.Domain.Apps.Entities.Apps.Invitation
public async Task HandleAsync(CommandContext context, Func<Task> next) public async Task HandleAsync(CommandContext context, Func<Task> next)
{ {
if (context.Command is AssignContributor assignContributor) if (context.Command is AssignContributor assignContributor && ShouldInvite(assignContributor))
{ {
if (assignContributor.IsInviting && assignContributor.ContributorId.IsEmail()) var created = await userResolver.CreateUserIfNotExists(assignContributor.ContributorId, true);
{
assignContributor.IsCreated = await userResolver.CreateUserIfNotExists(assignContributor.ContributorId, true);
await next();
if (assignContributor.IsCreated && context.PlainResult is IAppEntity app) await next();
{
context.Complete(new InvitedResult { App = app });
}
return; if (created && context.PlainResult is IAppEntity app)
{
context.Complete(new InvitedResult { App = app });
} }
} }
else
{
await next();
}
}
await next(); private bool ShouldInvite(AssignContributor assignContributor)
{
return assignContributor.Invite && assignContributor.ContributorId.IsEmail();
} }
} }
} }

8
src/Squidex.Domain.Apps.Entities/Assets/AssetGrain.cs

@ -52,8 +52,8 @@ namespace Squidex.Domain.Apps.Entities.Assets
switch (command) switch (command)
{ {
case CreateAsset createRule: case CreateAsset createAsset:
return CreateReturnAsync(createRule, async c => return CreateReturnAsync(createAsset, async c =>
{ {
GuardAsset.CanCreate(c); GuardAsset.CanCreate(c);
@ -63,8 +63,8 @@ namespace Squidex.Domain.Apps.Entities.Assets
return Snapshot; return Snapshot;
}); });
case UpdateAsset updateRule: case UpdateAsset updateAsset:
return UpdateReturn(updateRule, c => return UpdateReturn(updateAsset, c =>
{ {
GuardAsset.CanUpdate(c); GuardAsset.CanUpdate(c);

5
src/Squidex/Areas/Api/Controllers/Apps/Models/AssignContributorDto.cs

@ -8,6 +8,7 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Domain.Apps.Entities.Apps.Commands;
using Squidex.Infrastructure.Reflection; using Squidex.Infrastructure.Reflection;
using Roles = Squidex.Domain.Apps.Core.Apps.Role;
namespace Squidex.Areas.Api.Controllers.Apps.Models namespace Squidex.Areas.Api.Controllers.Apps.Models
{ {
@ -22,7 +23,7 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
/// <summary> /// <summary>
/// The role of the contributor. /// The role of the contributor.
/// </summary> /// </summary>
public string Role { get; set; } public string Role { get; set; } = Roles.Developer;
/// <summary> /// <summary>
/// Set to true to invite the user if he does not exist. /// Set to true to invite the user if he does not exist.
@ -31,7 +32,7 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
public AssignContributor ToCommand() public AssignContributor ToCommand()
{ {
return SimpleMapper.Map(this, new AssignContributor { IsInviting = Invite }); return SimpleMapper.Map(this, new AssignContributor());
} }
} }
} }

2
src/Squidex/app/features/schemas/pages/schema/schema-edit-form.component.scss

@ -1,6 +1,6 @@
@import '_vars'; @import '_vars';
@import '_mixins'; @import '_mixins';
texarea { textarea {
resize: none; resize: none;
} }

1
src/Squidex/app/features/settings/declarations.ts

@ -10,6 +10,7 @@ export * from './pages/backups/pipes';
export * from './pages/clients/client.component'; export * from './pages/clients/client.component';
export * from './pages/clients/clients-page.component'; export * from './pages/clients/clients-page.component';
export * from './pages/contributors/contributors-page.component'; export * from './pages/contributors/contributors-page.component';
export * from './pages/contributors/import-contributors-dialog.component';
export * from './pages/languages/language.component'; export * from './pages/languages/language.component';
export * from './pages/languages/languages-page.component'; export * from './pages/languages/languages-page.component';
export * from './pages/more/more-page.component'; export * from './pages/more/more-page.component';

2
src/Squidex/app/features/settings/module.ts

@ -21,6 +21,7 @@ import {
ClientComponent, ClientComponent,
ClientsPageComponent, ClientsPageComponent,
ContributorsPageComponent, ContributorsPageComponent,
ImportContributorsDialogComponent,
LanguageComponent, LanguageComponent,
LanguagesPageComponent, LanguagesPageComponent,
MorePageComponent, MorePageComponent,
@ -203,6 +204,7 @@ const routes: Routes = [
ClientComponent, ClientComponent,
ClientsPageComponent, ClientsPageComponent,
ContributorsPageComponent, ContributorsPageComponent,
ImportContributorsDialogComponent,
LanguageComponent, LanguageComponent,
LanguagesPageComponent, LanguagesPageComponent,
MorePageComponent, MorePageComponent,

10
src/Squidex/app/features/settings/pages/backups/backups-page.component.html

@ -34,15 +34,7 @@
<div class="table-items-row" *ngFor="let backup of backups; trackBy: trackByBackup"> <div class="table-items-row" *ngFor="let backup of backups; trackBy: trackByBackup">
<div class="row"> <div class="row">
<div class="col-auto" [ngSwitch]="backup.status"> <div class="col-auto" [ngSwitch]="backup.status">
<div *ngSwitchCase="'Failed'" class="backup-status backup-status-failed"> <sqx-status-icon size="lg" [status]="backup.status"></sqx-status-icon>
<i class="icon-exclamation"></i>
</div>
<div *ngSwitchCase="'Completed'" class="backup-status backup-status-success">
<i class="icon-checkmark"></i>
</div>
<div *ngSwitchDefault class="backup-status backup-status-pending spin">
<i class="icon-hour-glass"></i>
</div>
</div> </div>
<div class="col-auto"> <div class="col-auto">
<div> <div>

27
src/Squidex/app/features/settings/pages/backups/backups-page.component.scss

@ -1,29 +1,2 @@
@import '_vars'; @import '_vars';
@import '_mixins'; @import '_mixins';
$circle-size: 2.8rem;
.backup-status {
& {
@include circle($circle-size);
line-height: $circle-size + .1rem;
text-align: center;
font-size: .4 * $circle-size;
font-weight: normal;
background: $color-border;
color: $color-dark-foreground;
vertical-align: middle;
}
&-pending {
color: inherit;
}
&-failed {
background: $color-theme-error;
}
&-success {
background: $color-theme-green;
}
}

10
src/Squidex/app/features/settings/pages/contributors/contributors-page.component.html

@ -74,6 +74,10 @@
</div> </div>
</form> </form>
</div> </div>
<sqx-form-hint class="text-right">
Big team? <a class="force" (click)="importDialog.show()">Hide many contributors at once</a>
</sqx-form-hint>
</ng-container> </ng-container>
</ng-container> </ng-container>
</ng-container> </ng-container>
@ -92,4 +96,10 @@
</ng-container> </ng-container>
</sqx-panel> </sqx-panel>
<ng-container *sqxModal="importDialog">
<sqx-import-contributors-dialog
(close)="importDialog.hide()">
</sqx-import-contributors-dialog>
</ng-container>
<router-outlet></router-outlet> <router-outlet></router-outlet>

15
src/Squidex/app/features/settings/pages/contributors/contributors-page.component.ts

@ -16,10 +16,9 @@ import {
AutocompleteSource, AutocompleteSource,
ContributorDto, ContributorDto,
ContributorsState, ContributorsState,
DialogModel,
DialogService, DialogService,
RolesState, RolesState,
Types,
UserDto,
UsersService UsersService
} from '@app/shared'; } from '@app/shared';
@ -57,6 +56,8 @@ export class UsersDataSource implements AutocompleteSource {
export class ContributorsPageComponent implements OnInit { export class ContributorsPageComponent implements OnInit {
public assignContributorForm = new AssignContributorForm(this.formBuilder); public assignContributorForm = new AssignContributorForm(this.formBuilder);
public importDialog = new DialogModel();
constructor( constructor(
public readonly appsState: AppsState, public readonly appsState: AppsState,
public readonly contributorsState: ContributorsState, public readonly contributorsState: ContributorsState,
@ -89,15 +90,7 @@ export class ContributorsPageComponent implements OnInit {
const value = this.assignContributorForm.submit(); const value = this.assignContributorForm.submit();
if (value) { if (value) {
let user = value.user; this.contributorsState.assign(value)
if (Types.is(user, UserDto)) {
user = user.id;
}
const requestDto = { contributorId: user, role: 'Editor', invite: true };
this.contributorsState.assign(requestDto)
.subscribe(isCreated => { .subscribe(isCreated => {
this.assignContributorForm.submitCompleted(); this.assignContributorForm.submitCompleted();

43
src/Squidex/app/features/settings/pages/contributors/import-contributors-dialog.component.html

@ -0,0 +1,43 @@
<form [formGroup]="importForm.form" (ngSubmit)="import()">
<sqx-modal-dialog (close)="emitClose()">
<ng-container title>
Import contributors
</ng-container>
<ng-container content>
<div class="content">
<ng-container *ngIf="importStatus; else noStatus">
<div class="row pb-2" *ngFor="let status of importStatus">
<div class="col truncate">
{{status.email}}
</div>
<div class="col-auto">
<sqx-status-icon size="sm"
[status]="status.result"
[statusText]="status.resultText">
</sqx-status-icon>
</div>
</div>
</ng-container>
<ng-template #noStatus>
<textarea class="form-control content" placeholder="user1@squidex.io;user2.squidex.io" formControlName="import"></textarea>
</ng-template>
</div>
<sqx-form-hint>
<ng-container *ngIf="importForm.numberOfEmails | async; let emails">
Emails detected: {{emails}}
</ng-container>
&nbsp;
</sqx-form-hint>
</ng-container>
<ng-container footer>
<button type="reset" class="float-left btn btn-secondary" (click)="emitClose()">Cancel</button>
<button type="submit" class="float-right btn btn-success" [disabled]="importStatus || (importForm.hasNoUser | async)">Add Contributors</button>
</ng-container>
</sqx-modal-dialog>
</form>

10
src/Squidex/app/features/settings/pages/contributors/import-contributors-dialog.component.scss

@ -0,0 +1,10 @@
@import '_vars';
@import '_mixins';
textarea {
resize: none;
}
.content {
min-height: 300px;
}

96
src/Squidex/app/features/settings/pages/contributors/import-contributors-dialog.component.ts

@ -0,0 +1,96 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Component, EventEmitter, Output } from '@angular/core';
import { FormBuilder } from '@angular/forms';
import { empty, of } from 'rxjs';
import { catchError, mergeMap, tap } from 'rxjs/operators';
import {
ContributorsState,
ErrorDto,
ImportContributorsForm
} from '@app/shared';
interface ImportStatus {
email: string;
result: 'Pending' | 'Failed' | 'Success';
resultText: string;
}
@Component({
selector: 'sqx-import-contributors-dialog',
styleUrls: ['./import-contributors-dialog.component.scss'],
templateUrl: './import-contributors-dialog.component.html'
})
export class ImportContributorsDialogComponent {
@Output()
public close = new EventEmitter();
public importForm = new ImportContributorsForm(this.formBuilder);
public importStatus: ImportStatus[];
constructor(
private readonly formBuilder: FormBuilder,
private readonly contributorsState: ContributorsState
) {
}
public import() {
const contributors = this.importForm.submit();
if (contributors && contributors.length > 0) {
this.importStatus = [];
for (let contributor of contributors) {
this.importStatus.push({
email: contributor.contributorId,
result: 'Pending',
resultText: 'Pending'
});
}
of(...contributors).pipe(
mergeMap(c =>
this.contributorsState.assign(c, { silent: true }).pipe(
tap(created => {
let status = this.importStatus.find(x => x.email === c.contributorId);
if (status) {
status.resultText = getSuccess(created);
status.result = 'Success';
}
}),
catchError((error: ErrorDto) => {
let status = this.importStatus.find(x => x.email === c.contributorId);
if (status) {
status.resultText = getError(error);
status.result = 'Failed';
}
return empty();
})
), 1)
).subscribe();
}
}
public emitClose() {
this.close.emit();
}
}
function getError(error: ErrorDto): string {
return error.details[0];
}
function getSuccess(created: boolean | undefined): string {
return created ?
'User has been invited and assigned.' :
'User has been assigned';
}

9
src/Squidex/app/framework/angular/forms/form-hint.component.ts

@ -5,14 +5,17 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { ChangeDetectionStrategy, Component } from '@angular/core'; import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
@Component({ @Component({
selector: 'sqx-form-hint', selector: 'sqx-form-hint',
template: ` template: `
<small class="text-muted form-text"> <small class="text-muted form-text {{class}}">
<ng-content></ng-content> <ng-content></ng-content>
</small>`, </small>`,
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class FormHintComponent {} export class FormHintComponent {
@Input()
public class: string;
}

14
src/Squidex/app/framework/angular/status-icon.component.html

@ -0,0 +1,14 @@
<ng-container [ngSwitch]="status">
<div *ngSwitchCase="'Failed'" class="status status-failed {{size}}" [title]="statusText">
<i class="icon-exclamation"></i>
</div>
<div *ngSwitchCase="'Success'" class="status status-success {{size}}" [title]="statusText">
<i class="icon-checkmark"></i>
</div>
<div *ngSwitchCase="'Completed'" class="status status-success {{size}}" [title]="statusText">
<i class="icon-checkmark"></i>
</div>
<div *ngSwitchDefault class="status status-pending spin {{size}}" [title]="statusText">
<i class="icon-hour-glass"></i>
</div>
</ng-container>

41
src/Squidex/app/framework/angular/status-icon.component.scss

@ -0,0 +1,41 @@
@import '_mixins';
@import '_vars';
$circle-size-sm: 1.6rem;
$circle-size-lg: 2.8rem;
.status {
& {
text-align: center;
background: $color-border;
color: $color-dark-foreground;
vertical-align: middle;
}
&.sm {
@include circle($circle-size-sm);
line-height: $circle-size-sm + .1rem;
font-size: .5 * $circle-size-sm;
font-weight: normal;
}
&.lg {
@include circle($circle-size-lg);
line-height: $circle-size-lg + .1rem;
font-size: .5 * $circle-size-lg;
font-weight: normal;
}
&-pending {
color: inherit;
}
&-failed {
background: $color-theme-error;
}
&-success {
background: $color-theme-green;
}
}

25
src/Squidex/app/framework/angular/status-icon.component.ts

@ -0,0 +1,25 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
@Component({
selector: 'sqx-status-icon',
styleUrls: ['./status-icon.component.scss'],
templateUrl: './status-icon.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class StatusIconComponent {
@Input()
public status: 'Started' | 'Failed' | 'Success' | 'Completed' | 'Failed' | 'Pending';
@Input()
public statusText: string;
@Input()
public size: 'lg' | 'sm' = 'lg';
}

7
src/Squidex/app/framework/declarations.ts

@ -55,18 +55,19 @@ export * from './angular/routers/parent-link.directive';
export * from './angular/code.component'; export * from './angular/code.component';
export * from './angular/external-link.directive'; export * from './angular/external-link.directive';
export * from './angular/hover-background.directive';
export * from './angular/highlight.pipe'; export * from './angular/highlight.pipe';
export * from './angular/hover-background.directive';
export * from './angular/ignore-scrollbar.directive'; export * from './angular/ignore-scrollbar.directive';
export * from './angular/image-source.directive'; export * from './angular/image-source.directive';
export * from './angular/panel.component';
export * from './angular/panel-container.directive';
export * from './angular/pager.component'; export * from './angular/pager.component';
export * from './angular/panel-container.directive';
export * from './angular/panel.component';
export * from './angular/popup-link.directive'; export * from './angular/popup-link.directive';
export * from './angular/safe-html.pipe'; export * from './angular/safe-html.pipe';
export * from './angular/scroll-active.directive'; export * from './angular/scroll-active.directive';
export * from './angular/shortcut.component'; export * from './angular/shortcut.component';
export * from './angular/sorted.directive'; export * from './angular/sorted.directive';
export * from './angular/status-icon.component';
export * from './angular/stop-click.directive'; export * from './angular/stop-click.directive';
export * from './angular/sync-scrolling.directive'; export * from './angular/sync-scrolling.directive';
export * from './angular/template-wrapper.directive'; export * from './angular/template-wrapper.directive';

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

@ -82,6 +82,7 @@ import {
ShortTimePipe, ShortTimePipe,
SortedDirective, SortedDirective,
StarsComponent, StarsComponent,
StatusIconComponent,
StopClickDirective, StopClickDirective,
SyncScollingDirective, SyncScollingDirective,
TagEditorComponent, TagEditorComponent,
@ -159,6 +160,7 @@ import {
ShortTimePipe, ShortTimePipe,
SortedDirective, SortedDirective,
StarsComponent, StarsComponent,
StatusIconComponent,
StopClickDirective, StopClickDirective,
SyncScollingDirective, SyncScollingDirective,
TagEditorComponent, TagEditorComponent,
@ -230,6 +232,7 @@ import {
ShortTimePipe, ShortTimePipe,
SortedDirective, SortedDirective,
StarsComponent, StarsComponent,
StatusIconComponent,
StopClickDirective, StopClickDirective,
SyncScollingDirective, SyncScollingDirective,
TagEditorComponent, TagEditorComponent,

58
src/Squidex/app/shared/state/contributors.forms.ts

@ -6,12 +6,20 @@
*/ */
import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { map } from 'rxjs/operators';
import { Form, hasNoValue$ } from '@app/framework'; import {
Form,
hasNoValue$,
Types,
value$
} from '@app/framework';
import { AssignContributorDto } from '../services/contributors.service';
import { UserDto } from './../services/users.service'; import { UserDto } from './../services/users.service';
export class AssignContributorForm extends Form<FormGroup, { user: string | UserDto }> { export class AssignContributorForm extends Form<FormGroup, AssignContributorDto> {
public hasNoUser = hasNoValue$(this.form.controls['user']); public hasNoUser = hasNoValue$(this.form.controls['user']);
constructor(formBuilder: FormBuilder) { constructor(formBuilder: FormBuilder) {
@ -23,4 +31,50 @@ export class AssignContributorForm extends Form<FormGroup, { user: string | User
] ]
})); }));
} }
protected transformSubmit(value: string | UserDto) {
if (Types.is(value, UserDto)) {
value = value.id;
}
return { contributorId: value, role: 'Editor', invite: true };
}
} }
export class ImportContributorsForm extends Form<FormGroup, AssignContributorDto[]> {
public numberOfEmails = value$(this.form.controls['import']).pipe(map(v => extractEmails(v).length));
public hasNoUser = this.numberOfEmails.pipe(map(v => v === 0));
constructor(formBuilder: FormBuilder) {
super(formBuilder.group({
import: ['',
[
Validators.required
]
]
}));
}
protected transformSubmit(value: { import: string }) {
return extractEmails(value.import);
}
}
function extractEmails(value: string) {
let result: AssignContributorDto[] = [];
if (value) {
let emails = value.match(EMAIL_REGEX);
if (emails) {
for (let match of emails) {
result.push({ contributorId: match, role: 'Editor', invite: true });
}
}
}
return result;
}
const EMAIL_REGEX = /(?=.{1,254}$)(?=.{1,64}@)[-!#$%&'*+\/0-9=?A-Z^_`a-z{|}~]+(\.[-!#$%&'*+\/0-9=?A-Z^_`a-z{|}~]+)*@[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*/gim;

4
src/Squidex/app/shared/state/contributors.state.ts

@ -94,7 +94,7 @@ export class ContributorsState extends State<Snapshot> {
shareSubscribed(this.dialogs)); shareSubscribed(this.dialogs));
} }
public assign(request: AssignContributorDto): Observable<boolean | undefined> { public assign(request: AssignContributorDto, options?: { silent: boolean }): Observable<boolean | undefined> {
return this.contributorsService.postContributor(this.appName, request, this.version).pipe( return this.contributorsService.postContributor(this.appName, request, this.version).pipe(
catchError(error => { catchError(error => {
if (Types.is(error, ErrorDto) && error.statusCode === 404) { if (Types.is(error, ErrorDto) && error.statusCode === 404) {
@ -106,7 +106,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')); shareMapSubscribed(this.dialogs, x => x.payload._meta && x.payload._meta['isInvited'] === '1', options));
} }
private replaceContributors(version: Version, payload: ContributorsPayload) { private replaceContributors(version: Version, payload: ContributorsPayload) {

6
tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppContributorsTests.cs

@ -146,7 +146,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards
{ {
var command = new AssignContributor { ContributorId = "1" }; var command = new AssignContributor { ContributorId = "1" };
var contributors_1 = contributors_0.Assign("1", Role.Editor); var contributors_1 = contributors_0.Assign("1", Role.Developer);
await GuardAppContributors.CanAssign(contributors_1, roles, command, users, appPlan); await GuardAppContributors.CanAssign(contributors_1, roles, command, users, appPlan);
} }
@ -159,8 +159,8 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards
var command = new AssignContributor { ContributorId = "1" }; var command = new AssignContributor { ContributorId = "1" };
var contributors_1 = contributors_0.Assign("1", Role.Editor); var contributors_1 = contributors_0.Assign("1", Role.Developer);
var contributors_2 = contributors_1.Assign("2", Role.Editor); var contributors_2 = contributors_1.Assign("2", Role.Developer);
await GuardAppContributors.CanAssign(contributors_2, roles, command, users, appPlan); await GuardAppContributors.CanAssign(contributors_2, roles, command, users, appPlan);
} }

48
tests/Squidex.Domain.Apps.Entities.Tests/Apps/Invitation/InviteUserCommandMiddlewareTests.cs

@ -20,6 +20,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Invitation
public class InviteUserCommandMiddlewareTests public class InviteUserCommandMiddlewareTests
{ {
private readonly IUserResolver userResolver = A.Fake<IUserResolver>(); private readonly IUserResolver userResolver = A.Fake<IUserResolver>();
private readonly IAppEntity app = Mocks.App(NamedId.Of(Guid.NewGuid(), "my-app"));
private readonly ICommandBus commandBus = A.Fake<ICommandBus>(); private readonly ICommandBus commandBus = A.Fake<ICommandBus>();
private readonly InviteUserCommandMiddleware sut; private readonly InviteUserCommandMiddleware sut;
@ -31,19 +32,16 @@ namespace Squidex.Domain.Apps.Entities.Apps.Invitation
[Fact] [Fact]
public async Task Should_invite_user_and_change_result() public async Task Should_invite_user_and_change_result()
{ {
var command = new AssignContributor { ContributorId = "me@email.com", IsInviting = true }; var context =
var context = new CommandContext(command, commandBus); new CommandContext(new AssignContributor { ContributorId = "me@email.com", Invite = true }, commandBus)
.Complete(app);
A.CallTo(() => userResolver.CreateUserIfNotExists("me@email.com", true)) A.CallTo(() => userResolver.CreateUserIfNotExists("me@email.com", true))
.Returns(true); .Returns(true);
var result = Mocks.App(NamedId.Of(Guid.NewGuid(), "my-app"));
context.Complete(result);
await sut.HandleAsync(context); await sut.HandleAsync(context);
Assert.Same(context.Result<InvitedResult>().App, result); Assert.Same(context.Result<InvitedResult>().App, app);
A.CallTo(() => userResolver.CreateUserIfNotExists("me@email.com", true)) A.CallTo(() => userResolver.CreateUserIfNotExists("me@email.com", true))
.MustHaveHappened(); .MustHaveHappened();
@ -52,34 +50,50 @@ namespace Squidex.Domain.Apps.Entities.Apps.Invitation
[Fact] [Fact]
public async Task Should_invite_user_and_not_change_result_if_not_added() public async Task Should_invite_user_and_not_change_result_if_not_added()
{ {
var command = new AssignContributor { ContributorId = "me@email.com", IsInviting = true }; var context =
var context = new CommandContext(command, commandBus); new CommandContext(new AssignContributor { ContributorId = "me@email.com", Invite = true }, commandBus)
.Complete(app);
A.CallTo(() => userResolver.CreateUserIfNotExists("me@email.com", true)) A.CallTo(() => userResolver.CreateUserIfNotExists("me@email.com", true))
.Returns(false); .Returns(false);
var result = Mocks.App(NamedId.Of(Guid.NewGuid(), "my-app"));
context.Complete(result);
await sut.HandleAsync(context); await sut.HandleAsync(context);
Assert.Same(context.Result<IAppEntity>(), result); Assert.Same(context.Result<IAppEntity>(), app);
A.CallTo(() => userResolver.CreateUserIfNotExists("me@email.com", true)) A.CallTo(() => userResolver.CreateUserIfNotExists("me@email.com", true))
.MustHaveHappened(); .MustHaveHappened();
} }
[Fact] [Fact]
public async Task Should_not_calls_user_resolver_if_not_email() public async Task Should_not_call_user_resolver_if_not_email()
{
var context =
new CommandContext(new AssignContributor { ContributorId = "123", Invite = true }, commandBus)
.Complete(app);
await sut.HandleAsync(context);
A.CallTo(() => userResolver.CreateUserIfNotExists(A<string>.Ignored, A<bool>.Ignored))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_not_call_user_resolver_if_not_inviting()
{ {
var command = new AssignContributor { ContributorId = "123", IsInviting = true }; var context =
var context = new CommandContext(command, commandBus); new CommandContext(new AssignContributor { ContributorId = "123", Invite = false }, commandBus)
.Complete(app);
await sut.HandleAsync(context); await sut.HandleAsync(context);
A.CallTo(() => userResolver.CreateUserIfNotExists(A<string>.Ignored, A<bool>.Ignored)) A.CallTo(() => userResolver.CreateUserIfNotExists(A<string>.Ignored, A<bool>.Ignored))
.MustNotHaveHappened(); .MustNotHaveHappened();
} }
private CommandContext Context(AssignContributor command)
{
return new CommandContext(command, commandBus);
}
} }
} }

7
tests/Squidex.Domain.Apps.Entities.Tests/Schemas/Indexes/SchemasByAppIndexCommandMiddlewareTests.cs

@ -38,10 +38,9 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes
[Fact] [Fact]
public async Task Should_add_schema_to_index_on_create() public async Task Should_add_schema_to_index_on_create()
{ {
var command = new CreateSchema { SchemaId = schemaId.Id, Name = schemaId.Name, AppId = appId }; var context =
var context = new CommandContext(command, commandBus); new CommandContext(new CreateSchema { SchemaId = schemaId.Id, Name = schemaId.Name, AppId = appId }, commandBus)
.Complete();
context.Complete();
await sut.HandleAsync(context); await sut.HandleAsync(context);

13
tests/Squidex.Web.Tests/CommandMiddlewares/ETagCommandMiddlewareTests.cs

@ -38,7 +38,7 @@ namespace Squidex.Web.CommandMiddlewares
.Returns(null); .Returns(null);
var command = new CreateContent(); var command = new CreateContent();
var context = new CommandContext(command, commandBus); var context = Ctx(command);
await sut.HandleAsync(context); await sut.HandleAsync(context);
@ -51,7 +51,7 @@ namespace Squidex.Web.CommandMiddlewares
httpContext.Request.Headers[HeaderNames.IfMatch] = "13"; httpContext.Request.Headers[HeaderNames.IfMatch] = "13";
var command = new CreateContent(); var command = new CreateContent();
var context = new CommandContext(command, commandBus); var context = Ctx(command);
await sut.HandleAsync(context); await sut.HandleAsync(context);
@ -64,7 +64,7 @@ namespace Squidex.Web.CommandMiddlewares
httpContext.Request.Headers[HeaderNames.IfMatch] = "W/13"; httpContext.Request.Headers[HeaderNames.IfMatch] = "W/13";
var command = new CreateContent(); var command = new CreateContent();
var context = new CommandContext(command, commandBus); var context = Ctx(command);
await sut.HandleAsync(context); await sut.HandleAsync(context);
@ -75,7 +75,7 @@ namespace Squidex.Web.CommandMiddlewares
public async Task Should_add_etag_header_to_response() public async Task Should_add_etag_header_to_response()
{ {
var command = new CreateContent(); var command = new CreateContent();
var context = new CommandContext(command, commandBus); var context = Ctx(command);
context.Complete(new EntitySavedResult(17)); context.Complete(new EntitySavedResult(17));
@ -83,5 +83,10 @@ namespace Squidex.Web.CommandMiddlewares
Assert.Equal(new StringValues("17"), httpContextAccessor.HttpContext.Response.Headers[HeaderNames.ETag]); Assert.Equal(new StringValues("17"), httpContextAccessor.HttpContext.Response.Headers[HeaderNames.ETag]);
} }
private CommandContext Ctx(ICommand command)
{
return new CommandContext(command, commandBus);
}
} }
} }

15
tests/Squidex.Web.Tests/CommandMiddlewares/EnrichWithActorCommandMiddlewareTests.cs

@ -37,7 +37,7 @@ namespace Squidex.Web.CommandMiddlewares
public async Task Should_throw_security_exception_when_no_subject_or_client_is_found() public async Task Should_throw_security_exception_when_no_subject_or_client_is_found()
{ {
var command = new CreateContent(); var command = new CreateContent();
var context = new CommandContext(command, commandBus); var context = Ctx(command);
await Assert.ThrowsAsync<SecurityException>(() => sut.HandleAsync(context)); await Assert.ThrowsAsync<SecurityException>(() => sut.HandleAsync(context));
} }
@ -49,7 +49,7 @@ namespace Squidex.Web.CommandMiddlewares
.Returns(null); .Returns(null);
var command = new CreateContent(); var command = new CreateContent();
var context = new CommandContext(command, commandBus); var context = Ctx(command);
await sut.HandleAsync(context); await sut.HandleAsync(context);
@ -62,7 +62,7 @@ namespace Squidex.Web.CommandMiddlewares
httpContext.User = CreatePrincipal(OpenIdClaims.Subject, "me"); httpContext.User = CreatePrincipal(OpenIdClaims.Subject, "me");
var command = new CreateContent(); var command = new CreateContent();
var context = new CommandContext(command, commandBus); var context = Ctx(command);
await sut.HandleAsync(context); await sut.HandleAsync(context);
@ -75,7 +75,7 @@ namespace Squidex.Web.CommandMiddlewares
httpContext.User = CreatePrincipal(OpenIdClaims.ClientId, "my-client"); httpContext.User = CreatePrincipal(OpenIdClaims.ClientId, "my-client");
var command = new CreateContent(); var command = new CreateContent();
var context = new CommandContext(command, commandBus); var context = Ctx(command);
await sut.HandleAsync(context); await sut.HandleAsync(context);
@ -88,13 +88,18 @@ namespace Squidex.Web.CommandMiddlewares
httpContext.User = CreatePrincipal(OpenIdClaims.ClientId, "my-client"); httpContext.User = CreatePrincipal(OpenIdClaims.ClientId, "my-client");
var command = new CreateContent { Actor = new RefToken("subject", "me") }; var command = new CreateContent { Actor = new RefToken("subject", "me") };
var context = new CommandContext(command, commandBus); var context = Ctx(command);
await sut.HandleAsync(context); await sut.HandleAsync(context);
Assert.Equal(new RefToken("subject", "me"), command.Actor); Assert.Equal(new RefToken("subject", "me"), command.Actor);
} }
private CommandContext Ctx(ICommand command)
{
return new CommandContext(command, commandBus);
}
private static ClaimsPrincipal CreatePrincipal(string claimType, string claimValue) private static ClaimsPrincipal CreatePrincipal(string claimType, string claimValue)
{ {
var claimsPrincipal = new ClaimsPrincipal(); var claimsPrincipal = new ClaimsPrincipal();

15
tests/Squidex.Web.Tests/CommandMiddlewares/EnrichWithAppIdCommandMiddlewareTests.cs

@ -47,7 +47,7 @@ namespace Squidex.Web.CommandMiddlewares
appContext.App = null; appContext.App = null;
var command = new CreateContent(); var command = new CreateContent();
var context = new CommandContext(command, commandBus); var context = Ctx(command);
await Assert.ThrowsAsync<InvalidOperationException>(() => sut.HandleAsync(context)); await Assert.ThrowsAsync<InvalidOperationException>(() => sut.HandleAsync(context));
} }
@ -56,7 +56,7 @@ namespace Squidex.Web.CommandMiddlewares
public async Task Should_assign_app_id_and_name_to_app_command() public async Task Should_assign_app_id_and_name_to_app_command()
{ {
var command = new CreateContent(); var command = new CreateContent();
var context = new CommandContext(command, commandBus); var context = Ctx(command);
await sut.HandleAsync(context); await sut.HandleAsync(context);
@ -67,7 +67,7 @@ namespace Squidex.Web.CommandMiddlewares
public async Task Should_assign_app_id_to_app_self_command() public async Task Should_assign_app_id_to_app_self_command()
{ {
var command = new ChangePlan(); var command = new ChangePlan();
var context = new CommandContext(command, commandBus); var context = Ctx(command);
await sut.HandleAsync(context); await sut.HandleAsync(context);
@ -78,7 +78,7 @@ namespace Squidex.Web.CommandMiddlewares
public async Task Should_not_override_app_id() public async Task Should_not_override_app_id()
{ {
var command = new ChangePlan { AppId = Guid.NewGuid() }; var command = new ChangePlan { AppId = Guid.NewGuid() };
var context = new CommandContext(command, commandBus); var context = Ctx(command);
await sut.HandleAsync(context); await sut.HandleAsync(context);
@ -89,11 +89,16 @@ namespace Squidex.Web.CommandMiddlewares
public async Task Should_not_override_app_id_and_name() public async Task Should_not_override_app_id_and_name()
{ {
var command = new CreateContent { AppId = NamedId.Of(Guid.NewGuid(), "other-app") }; var command = new CreateContent { AppId = NamedId.Of(Guid.NewGuid(), "other-app") };
var context = new CommandContext(command, commandBus); var context = Ctx(command);
await sut.HandleAsync(context); await sut.HandleAsync(context);
Assert.NotEqual(appId, command.AppId); Assert.NotEqual(appId, command.AppId);
} }
private CommandContext Ctx(ICommand command)
{
return new CommandContext(command, commandBus);
}
} }
} }

19
tests/Squidex.Web.Tests/CommandMiddlewares/EnrichWithSchemaIdCommandMiddlewareTests.cs

@ -72,7 +72,7 @@ namespace Squidex.Web.CommandMiddlewares
actionContext.RouteData.Values["name"] = "other-schema"; actionContext.RouteData.Values["name"] = "other-schema";
var command = new CreateContent { AppId = appId }; var command = new CreateContent { AppId = appId };
var context = new CommandContext(command, commandBus); var context = Ctx(command);
await Assert.ThrowsAsync<DomainObjectNotFoundException>(() => sut.HandleAsync(context)); await Assert.ThrowsAsync<DomainObjectNotFoundException>(() => sut.HandleAsync(context));
} }
@ -81,7 +81,7 @@ namespace Squidex.Web.CommandMiddlewares
public async Task Should_do_nothing_when_route_has_no_parameter() public async Task Should_do_nothing_when_route_has_no_parameter()
{ {
var command = new CreateContent(); var command = new CreateContent();
var context = new CommandContext(command, commandBus); var context = Ctx(command);
await sut.HandleAsync(context); await sut.HandleAsync(context);
@ -94,7 +94,7 @@ namespace Squidex.Web.CommandMiddlewares
actionContext.RouteData.Values["name"] = schemaId.Name; actionContext.RouteData.Values["name"] = schemaId.Name;
var command = new CreateContent { AppId = appId }; var command = new CreateContent { AppId = appId };
var context = new CommandContext(command, commandBus); var context = Ctx(command);
await sut.HandleAsync(context); await sut.HandleAsync(context);
@ -107,7 +107,7 @@ namespace Squidex.Web.CommandMiddlewares
actionContext.RouteData.Values["name"] = schemaId.Id; actionContext.RouteData.Values["name"] = schemaId.Id;
var command = new CreateContent { AppId = appId }; var command = new CreateContent { AppId = appId };
var context = new CommandContext(command, commandBus); var context = Ctx(command);
await sut.HandleAsync(context); await sut.HandleAsync(context);
@ -120,7 +120,7 @@ namespace Squidex.Web.CommandMiddlewares
actionContext.RouteData.Values["name"] = schemaId.Name; actionContext.RouteData.Values["name"] = schemaId.Name;
var command = new UpdateSchema(); var command = new UpdateSchema();
var context = new CommandContext(command, commandBus); var context = Ctx(command);
await sut.HandleAsync(context); await sut.HandleAsync(context);
@ -131,7 +131,7 @@ namespace Squidex.Web.CommandMiddlewares
public async Task Should_not_override_schema_id() public async Task Should_not_override_schema_id()
{ {
var command = new CreateSchema { SchemaId = Guid.NewGuid() }; var command = new CreateSchema { SchemaId = Guid.NewGuid() };
var context = new CommandContext(command, commandBus); var context = Ctx(command);
await sut.HandleAsync(context); await sut.HandleAsync(context);
@ -142,11 +142,16 @@ namespace Squidex.Web.CommandMiddlewares
public async Task Should_not_override_schema_id_and_name() public async Task Should_not_override_schema_id_and_name()
{ {
var command = new CreateContent { SchemaId = NamedId.Of(Guid.NewGuid(), "other-schema") }; var command = new CreateContent { SchemaId = NamedId.Of(Guid.NewGuid(), "other-schema") };
var context = new CommandContext(command, commandBus); var context = Ctx(command);
await sut.HandleAsync(context); await sut.HandleAsync(context);
Assert.NotEqual(appId, command.AppId); Assert.NotEqual(appId, command.AppId);
} }
private CommandContext Ctx(ICommand command)
{
return new CommandContext(command, commandBus);
}
} }
} }

Loading…
Cancel
Save