feat(employee): Added functionality to invite multiple emailaddresses. (TV-512)

Squashed commit of the following:

commit 04baa8e9398016ffb0cba618a5b857230dc4ea9e
Author: Erik Tiekstra <erik.tiekstra@arbetsformedlingen.se>
Date:   Mon Sep 6 10:52:12 2021 +0200

    moved email-regex

commit 0f54392c2e65386f4d0ae79493f6d33d84e313fe
Author: Erik Tiekstra <erik.tiekstra@arbetsformedlingen.se>
Date:   Mon Sep 6 07:54:13 2021 +0200

    Updated confirmation texts

commit 4557c8203cf826caccff7ac174e15b547f041993
Merge: ec932ec ec7b4fc
Author: Erik Tiekstra <erik.tiekstra@arbetsformedlingen.se>
Date:   Mon Sep 6 07:37:56 2021 +0200

    Merge branch 'develop' into feature/TV-512-inbjudningar

commit ec932ecad69b96504b2d25f937aa4b6def646f11
Author: Erik Tiekstra <erik.tiekstra@arbetsformedlingen.se>
Date:   Fri Sep 3 16:08:22 2021 +0200

    Now possible to add commaseparated list of emails
This commit is contained in:
Erik Tiekstra
2021-09-06 10:53:25 +02:00
parent ec7b4fcd8e
commit 9253edfe62
8 changed files with 184 additions and 107 deletions

View File

@@ -1,6 +1,7 @@
<msfa-layout> <msfa-layout>
<section class="employee-invite">
<digi-typography> <digi-typography>
<section class="employee-invite">
<header class="employee-invite__header">
<h1>Skapa personalkonto</h1> <h1>Skapa personalkonto</h1>
<p>Så här skapar du ett personalkonto:</p> <p>Så här skapar du ett personalkonto:</p>
<ol> <ol>
@@ -14,50 +15,65 @@
personalen ska ha. Nu kan personalen logga in och arbeta. personalen ska ha. Nu kan personalen logga in och arbeta.
</li> </li>
</ol> </ol>
</digi-typography> </header>
<form [formGroup]="form" (ngSubmit)="submitForm()"> <form [formGroup]="formGroup" (ngSubmit)="submitForm()">
<div class="employee-invite__block"> <div class="employee-invite__description">
<digi-typography>
<h2>Skicka en inbjudningslänk</h2> <h2>Skicka en inbjudningslänk</h2>
<p> <p>
Skicka en inbjudningslänk till personalen du vill lägga till som systemanvändare. Ange personalens Skicka en inbjudningslänk till personalen du vill lägga till som systemanvändare. Ange personalens
e-postadress nedan och tryck på skicka inbjudningslänk. e-postadress nedan och tryck på skicka inbjudningslänk.
</p> </p>
</digi-typography> </div>
<div class="employee-invite__input-section"> <digi-ng-form-textarea
<digi-ng-form-input formControlName="emails"
afId="employee-invite-email" afSize="m"
class="employee-invite__input" afDescription="Fältet accepterar kommaseparera mailadresserna för att skicka inbjudan till flera e-postadresser i taget."
formControlName="email"
afLabel="E-postadress"
afType="email"
[afRequired]="true" [afRequired]="true"
[afInvalidMessage]="emailControl.errors?.message || 'Ogiltig e-postadress'"
[afDisableValidStyle]="true" [afDisableValidStyle]="true"
[afInvalid]="emailControl.invalid && emailControl.dirty" [afInvalidMessage]="emailsControl.errors?.message || 'Ogiltig e-postadress'"
></digi-ng-form-input> [afInvalid]="emailsControl.invalid && emailsControl.dirty"
<digi-button af-size="m" af-type="submit" class="employee-invite__invitation-btn" afLabel="E-postadresser"
>Skicka inbjudningslänk</digi-button ></digi-ng-form-textarea>
>
</div>
</div>
<digi-notification-alert <digi-notification-alert
*ngIf="(lastInvite$ | async) as lastInvite" *ngIf="(lastInvites$ | async) && !emailsControl.dirty"
af-variation="success" class="employee-invite__alert"
af-heading="Allt gick bra" [afVariation]="alertTexts.variation"
[afHeading]="alertTexts.heading"
af-heading-level="h3" af-heading-level="h3"
af-closeable="true"
(click)="onCloseAlert()"
> >
<p *ngIf="lastInvitedNewUsers?.length">Inbjudan har skickats till {{lastInvitedNewUsers.join(', ')}}.</p> <ng-container *ngIf="alertTexts.variation === 'warning'">
<p *ngIf="lastInvitedExistingUsers?.length"> <p>
Följande personal är redan registrerat: {{lastInvitedExistingUsers.join(', ')}}. Inbjudan skickades endast till vissa mottagare. Skicka inbjudningar igen till de e-postadresser där
inbjudan misslyckades.
</p> </p>
</ng-container>
<ng-container *ngIf="alertTexts.variation === 'danger'">
<p>Något gick fel. Skicka inbjudningar igen.</p>
</ng-container>
<ng-container *ngIf="lastInvitedFailedInvites.length">
<h4>Inbjudan kunde inte skickas till:</h4>
<p>{{lastInvitedFailedInvites.join(', ')}}</p>
</ng-container>
<ng-container *ngIf="lastInvitedNewUsers.length">
<h4>Inbjudan har skickats till:</h4>
<p>{{lastInvitedNewUsers.join(', ')}}</p>
</ng-container>
<ng-container *ngIf="lastInvitedAlreadyInvitedUsers.length">
<h4>E-postadressen har redan fått en inbjudan:</h4>
<p>{{lastInvitedAlreadyInvitedUsers.join(', ')}}</p>
</ng-container>
<ng-container *ngIf="lastInvitedExistingUsers.length">
<h4>E-postadressen var redan registrerad:</h4>
<p>{{lastInvitedExistingUsers.join(', ')}}</p>
</ng-container>
</digi-notification-alert> </digi-notification-alert>
<footer class="employee-invite__footer"> <footer class="employee-invite__footer">
<digi-button af-size="m" af-type="submit"> Skicka inbjudningslänk </digi-button>
<msfa-back-link [route]="['/administration/personal']">Tillbaka till personallistan</msfa-back-link> <msfa-back-link [route]="['/administration/personal']">Tillbaka till personallistan</msfa-back-link>
</footer> </footer>
</form> </form>
</section> </section>
</digi-typography>
</msfa-layout> </msfa-layout>

View File

@@ -1,31 +1,30 @@
@import 'variables/gutters'; @import 'variables/gutters';
.employee-invite { .employee-invite {
&__block {
max-width: var(--digi--typography--text--max-width); max-width: var(--digi--typography--text--max-width);
margin-top: $digi--layout--gutter--xl;
&__header {
margin-bottom: $digi--layout--gutter--xxl;
}
&__description {
margin-bottom: $digi--layout--gutter--xl; margin-bottom: $digi--layout--gutter--xl;
} }
&__input-section { &__alert {
display: flex; h4 {
margin-top: $digi--layout--gutter--xl; margin-bottom: 0;
margin-top: $digi--layout--gutter;
} }
p {
&__input { margin: 0;
display: block;
min-width: 240px;
margin-bottom: var(--digi--layout--gutter);
} }
&__invitation-btn {
margin-top: 31px;
margin-left: 16px;
} }
&__footer { &__footer {
margin-top: $digi--layout--gutter--xl; margin-top: $digi--layout--gutter--xl;
display: flex; display: flex;
gap: var(--digi--layout--gutter); justify-content: space-between;
align-items: center;
} }
} }

View File

@@ -1,7 +1,8 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'; import { ChangeDetectionStrategy, Component } from '@angular/core';
import { AbstractControl, FormControl, FormGroup, Validators } from '@angular/forms'; import { AbstractControl, FormControl, FormGroup } from '@angular/forms';
import { EmployeeInviteResponse } from '@msfa-models/api/employee-invite.response.model'; import { EmployeeInviteResponse } from '@msfa-models/api/employee-invite.response.model';
import { EmployeeService } from '@msfa-services/api/employee.service'; import { EmployeeService } from '@msfa-services/api/employee.service';
import { CommaSeparatedEmailValidator } from '@msfa-utils/validators/email.validator';
import { RequiredValidator } from '@msfa-utils/validators/required.validator'; import { RequiredValidator } from '@msfa-utils/validators/required.validator';
import { BehaviorSubject, Observable } from 'rxjs'; import { BehaviorSubject, Observable } from 'rxjs';
@@ -12,48 +13,84 @@ import { BehaviorSubject, Observable } from 'rxjs';
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class EmployeeInviteComponent { export class EmployeeInviteComponent {
form: FormGroup = new FormGroup({ formGroup: FormGroup = new FormGroup({
email: new FormControl('', [ emails: new FormControl('', [RequiredValidator('E-postadresser'), CommaSeparatedEmailValidator()]),
RequiredValidator('E-postadress'),
Validators.pattern('^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,4}$'),
]),
}); });
private _lastInvite$ = new BehaviorSubject<EmployeeInviteResponse>(null); private _lastInvites$ = new BehaviorSubject<EmployeeInviteResponse>(null);
lastInvite$: Observable<EmployeeInviteResponse> = this._lastInvite$.asObservable(); lastInvites$: Observable<EmployeeInviteResponse> = this._lastInvites$.asObservable();
constructor(private employeeService: EmployeeService) {} constructor(private employeeService: EmployeeService) {}
get emailControl(): AbstractControl { get emailsControl(): AbstractControl {
return this.form.get('email'); return this.formGroup.get('emails');
} }
get lastInvite(): EmployeeInviteResponse { get lastInvites(): EmployeeInviteResponse {
return this._lastInvite$.getValue(); return this._lastInvites$.getValue();
}
get emailsControlValueAsArray(): string[] {
return (this.emailsControl.value as string)
.replaceAll(' ', '')
.split(',')
.filter(email => !!email);
} }
get lastInvitedNewUsers(): string[] { get lastInvitedNewUsers(): string[] {
const invitedUsers = this.lastInvite?.invitedUsers || []; const invitedUsers = this.lastInvites?.invitedUsers || [];
const assignedUsers = this.lastInvite?.assignedUsers || []; const assignedUsers = this.lastInvites?.assignedUsers || [];
return [...invitedUsers, ...assignedUsers.map(assigned => assigned.email)]; return [...invitedUsers, ...assignedUsers.map(user => user.email)];
}
get lastInvitedExistingUsers(): string[] {
const existingUsersInCurrentOrg = this.lastInvites?.existingUsersInCurrentOrg || [];
return existingUsersInCurrentOrg.map(user => user.email);
}
get lastInvitedFailedInvites(): string[] {
const failedInvites = this.lastInvites?.failedInvite || [];
return failedInvites;
}
get lastInvitedAlreadyInvitedUsers(): string[] {
const alreadyInvitedUsers = this.lastInvites?.alreadyInvitedUsers || [];
return alreadyInvitedUsers.map(user => user.email);
} }
get lastInvitedExistingUsers(): string[] { get alertTexts(): { variation: string; heading: string } {
const existingUsersInCurrentOrg = this.lastInvite?.existingUsersInCurrentOrg || []; if (this.lastInvitedFailedInvites.length) {
return existingUsersInCurrentOrg.map(existing => existing.email); if (
this.lastInvitedNewUsers.length ||
this.lastInvitedExistingUsers.length ||
this.lastInvitedAlreadyInvitedUsers.length
) {
return {
variation: 'warning',
heading: 'Något gick fel',
};
}
return {
variation: 'danger',
heading: 'Inbjudningar har inte skickats!',
};
}
return {
variation: 'success',
heading: 'Allt gick bra',
};
} }
submitForm(): void { submitForm(): void {
this._lastInvite$.next(null); this._lastInvites$.next(null);
if (this.form.invalid) { if (this.formGroup.invalid) {
this.emailControl.markAsDirty(); this.emailsControl.markAsDirty();
this.emailControl.markAsTouched(); this.emailsControl.markAsTouched();
return; return;
} }
const post = this.employeeService.postEmployeeInvitation(this.emailControl.value).subscribe({ const post = this.employeeService.postEmployeeInvitation(this.emailsControlValueAsArray).subscribe({
next: data => { next: data => {
this._lastInvite$.next(data); this._lastInvites$.next(data);
this.form.reset(); this.formGroup.reset();
}, },
complete: () => { complete: () => {
post.unsubscribe(); post.unsubscribe();
@@ -62,6 +99,6 @@ export class EmployeeInviteComponent {
} }
onCloseAlert(): void { onCloseAlert(): void {
this._lastInvite$.next(null); this._lastInvites$.next(null);
} }
} }

View File

@@ -1,4 +1,4 @@
import { DigiNgFormInputModule } from '@af/digi-ng/_form/form-input'; import { DigiNgFormTextareaModule } from '@af/digi-ng/_form/form-textarea';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms'; import { ReactiveFormsModule } from '@angular/forms';
@@ -16,7 +16,7 @@ import { EmployeeInviteComponent } from './employee-invite.component';
LayoutModule, LayoutModule,
BackLinkModule, BackLinkModule,
ReactiveFormsModule, ReactiveFormsModule,
DigiNgFormInputModule, DigiNgFormTextareaModule,
], ],
}) })
export class EmployeeInviteModule {} export class EmployeeInviteModule {}

View File

@@ -15,4 +15,6 @@ export interface EmployeeInviteResponse {
assignedUsers: ExistingUser[]; assignedUsers: ExistingUser[];
invitedUsers: string[]; invitedUsers: string[];
existingUsersInCurrentOrg: ExistingUser[]; existingUsersInCurrentOrg: ExistingUser[];
failedInvite: string[];
alreadyInvitedUsers: ExistingUser[];
} }

View File

@@ -182,11 +182,9 @@ export class EmployeeService extends UnsubscribeDirective {
); );
} }
public postEmployeeInvitation(email: string): Observable<EmployeeInviteResponse> { public postEmployeeInvitation(emails: string[]): Observable<EmployeeInviteResponse> {
return this.httpClient return this.httpClient
.patch<{ data: EmployeeInviteResponse }>(`${this._apiBaseUrl}/invite`, { .patch<{ data: EmployeeInviteResponse }>(`${this._apiBaseUrl}/invite`, { emails })
emails: [email],
})
.pipe( .pipe(
take(1), take(1),
map(({ data }) => data), map(({ data }) => data),

View File

@@ -1,14 +1,37 @@
import { AbstractControl, ValidatorFn } from '@angular/forms'; import { AbstractControl, ValidatorFn } from '@angular/forms';
import { ValidationError } from '@msfa-models/validation-error.model'; import { ValidationError } from '@msfa-models/validation-error.model';
export function EmailValidator(label = 'Fältet'): ValidatorFn { const EMAIL_REGEX = /^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,4}$/;
const emailRegex = /^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,4}$/;
export function EmailValidator(label = 'Fältet'): ValidatorFn {
return (control: AbstractControl): ValidationError => { return (control: AbstractControl): ValidationError => {
if (control && control.value && !emailRegex.test(control.value)) { if (control && control.value && !EMAIL_REGEX.test(control.value)) {
return { type: 'invalid', message: `Ogiltig ${label}` }; return { type: 'invalid', message: `Ogiltig ${label}` };
} }
return null; return null;
}; };
} }
export function CommaSeparatedEmailValidator(): ValidatorFn {
return (control: AbstractControl): ValidationError => {
if (control && control.value) {
const values: string[] = (control.value as string).replaceAll(' ', '').split(',');
const invalidEmailaddresses = [];
values.forEach(value => {
if (value && !EMAIL_REGEX.test(value)) {
invalidEmailaddresses.push(value);
}
});
if (invalidEmailaddresses.length) {
const messagePrepend =
invalidEmailaddresses.length > 1 ? 'Ogiltiga e-postadresser: ' : 'Ogiltig e-postadress: ';
return { type: 'invalid', message: `${messagePrepend}${invalidEmailaddresses.join(', ')}` };
}
}
return null;
};
}

View File

@@ -62,6 +62,8 @@ router.render = (req, res) => {
invitedUsers: req.body.emails, invitedUsers: req.body.emails,
assignedUsers: [], assignedUsers: [],
existingUsersInCurrentOrg: [], existingUsersInCurrentOrg: [],
failedInvite: [],
alreadyInvitedUsers: [],
}, },
}); });
} }