Skip to content

Commit 259e7bf

Browse files
authored
Add Pre-request manager (#761)
client, server and e2e-tests
1 parent dc555dd commit 259e7bf

File tree

61 files changed

+350
-94
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

61 files changed

+350
-94
lines changed

client/e2e/pages/pre-request-feedback-token.page.ts

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@ export class PreRequestFeedbackTokenPage {
55

66
async goto() {
77
await this.page.goto('/fr/request');
8+
}
9+
10+
// ----- Generate new magic link -----
811

12+
async generate(message = '') {
913
await this.page.getByRole('radio', { name: 'Avec un lien magique' }).check();
1014
await expect(this.page.getByText('Emails de vos collègues')).toBeDisabled();
11-
}
1215

13-
async generate(message = '') {
1416
if (message) {
1517
await this.page.getByRole('textbox', { name: 'Message' }).fill(message);
1618
}
@@ -19,7 +21,7 @@ export class PreRequestFeedbackTokenPage {
1921
await this.page.waitForURL('/fr/request/success');
2022
}
2123

22-
async getMagicLink(): Promise<string> {
24+
async getMagicLinkFromFromSuccess(): Promise<string> {
2325
await this.page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
2426

2527
await this.page.getByRole('button', { name: 'Lien magique' }).click();
@@ -29,9 +31,28 @@ export class PreRequestFeedbackTokenPage {
2931
return await this.page.evaluate(() => navigator.clipboard.readText());
3032
}
3133

32-
async gotoGenerateAndGetMagicLink(message = ''): Promise<string> {
34+
async gotoGenerateAndGetMagicLinkFromSuccess(message = ''): Promise<string> {
3335
await this.goto();
3436
await this.generate(message);
35-
return await this.getMagicLink();
37+
return await this.getMagicLinkFromFromSuccess();
38+
}
39+
40+
// ----- Check existing magic link -----
41+
42+
async checkMagicLinkFromDialog(rowIndex: number) {
43+
await this.page.getByRole('button', { name: 'Liens magiques' }).click();
44+
45+
await this.page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
46+
47+
await this.page.getByRole('button').filter({ hasText: 'content_copy' }).nth(rowIndex).click();
48+
49+
await this.page.getByRole('button', { name: 'Fermer' }).click();
50+
51+
return await this.page.evaluate(() => navigator.clipboard.readText());
52+
}
53+
54+
async gotoAndCheckMagicLinkFromDialog(rowIndex: number): Promise<string> {
55+
await this.goto();
56+
return await this.checkMagicLinkFromDialog(rowIndex);
3657
}
3758
}

client/e2e/pre-request-feedback.spec.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,16 @@ const feedbackMessage = 'Quel est votre feedback ?';
1111

1212
test.beforeEach(({ page }) => new FirestorePage(page).reset());
1313

14-
test('Submit spontaneous feedback', async ({ page }) => {
14+
test('Pre-request feedback', async ({ page }) => {
1515
// ====== Alfred generate magic link ======
1616

1717
await new SignInPage(page).gotoAndSignIn(Persona.Alfred);
1818

19-
const magicLink = await new PreRequestFeedbackTokenPage(page).gotoGenerateAndGetMagicLink(feedbackMessage);
19+
const magicLink = await new PreRequestFeedbackTokenPage(page).gotoGenerateAndGetMagicLinkFromSuccess(feedbackMessage);
20+
21+
const magicLinkFromDialog = await new PreRequestFeedbackTokenPage(page).gotoAndCheckMagicLinkFromDialog(0);
22+
23+
expect(magicLinkFromDialog, 'Magic link from dialog matches the one from success-page').toEqual(magicLink);
2024

2125
await new UserMenuPage(page).signOut();
2226

client/src/app/give-feedback/give-feedback/give-feedback.component.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,6 @@ import { giveFeedbackDraftDialogData } from './give-feedback.constants';
4949
encapsulation: ViewEncapsulation.None,
5050
})
5151
export class GiveFeedbackComponent implements UnsavedFormGuard {
52-
draftDialogTmpl = viewChild.required<TemplateRef<unknown>>('draftDialogTmpl');
53-
5452
private router = inject(Router);
5553

5654
private activatedRoute = inject(ActivatedRoute);
@@ -79,6 +77,8 @@ export class GiveFeedbackComponent implements UnsavedFormGuard {
7977

8078
private draftDialogRef?: MatDialogRef<unknown>;
8179

80+
draftDialogTmpl = viewChild.required<TemplateRef<unknown>>('draftDialogTmpl');
81+
8282
protected form = this.formBuilder.group({
8383
receiverEmail: [
8484
this.getQueryParam('receiverEmail'),
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
<table mat-table [dataSource]="list()" class="w-full">
2+
<ng-container matColumnDef="expiresInHours">
3+
<th mat-header-cell *matHeaderCellDef i18n="@@Component.FeedbackPreRequestList.ExpiresInHours">Expire dans</th>
4+
<td mat-cell *matCellDef="let element">{{ d(element).expiresInHours }}h</td>
5+
</ng-container>
6+
7+
<ng-container matColumnDef="remainingUses">
8+
<th mat-header-cell *matHeaderCellDef i18n="@@Component.FeedbackPreRequestList.RemainingUses">
9+
Utilisations restantes
10+
</th>
11+
<td mat-cell *matCellDef="let element">
12+
{{ d(element).remainingUses }}
13+
<ng-container i18n="@@Word.Times">{d(element).remainingUses, plural, =one {fois} =other {fois}}</ng-container>
14+
</td>
15+
</ng-container>
16+
17+
<ng-container matColumnDef="shared">
18+
<th mat-header-cell *matHeaderCellDef i18n="@@Component.FeedbackPreRequestList.Shared">Partagé</th>
19+
<td mat-cell *matCellDef="let element">
20+
@if (d(element).shared) {
21+
<mat-icon>check</mat-icon>
22+
}
23+
</td>
24+
</ng-container>
25+
26+
<ng-container matColumnDef="actions">
27+
<th mat-header-cell *matHeaderCellDef>&nbsp;</th>
28+
<td mat-cell *matCellDef="let element" class="w-0 whitespace-nowrap">
29+
@let magicLink = preRequestFeedbackService.buildMagicLink(d(element).token);
30+
@let isCopiedMagicLink = magicLink === copiedMagicLink();
31+
32+
<a
33+
[href]="magicLink"
34+
target="feedbackPreRequestPreview"
35+
class="gbl-sys-primary-hover"
36+
mat-icon-button
37+
matTooltipPosition="left"
38+
matTooltip="Aperçu dans un nouvel onglet"
39+
i18n-matTooltip="@@Component.FeedbackPreRequestList.Preview"
40+
>
41+
<mat-icon>open_in_new</mat-icon>
42+
</a>
43+
44+
<button
45+
class="gbl-sys-primary-hover"
46+
[style.color]="isCopiedMagicLink ? 'var(--mat-sys-primary)' : undefined"
47+
mat-icon-button
48+
matTooltipPosition="right"
49+
matTooltip="Copier dans le presse papier"
50+
i18n-matTooltip="@@Action.CopyToClipboard"
51+
(click)="toClipboard(magicLink)"
52+
>
53+
<mat-icon>
54+
@if (isCopiedMagicLink) {
55+
task_alt
56+
} @else {
57+
content_copy
58+
}
59+
</mat-icon>
60+
</button>
61+
</td>
62+
</ng-container>
63+
64+
<tr mat-header-row *matHeaderRowDef="columns"></tr>
65+
<tr mat-row *matRowDef="let row; columns: columns"></tr>
66+
</table>
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { Component, DOCUMENT, inject, input, signal, ViewEncapsulation } from '@angular/core';
2+
import { MatButtonModule } from '@angular/material/button';
3+
import { MatIconModule } from '@angular/material/icon';
4+
import { MatTableModule } from '@angular/material/table';
5+
import { MatTooltipModule } from '@angular/material/tooltip';
6+
import { PreRequestFeedbackService } from '../../shared/feedback';
7+
import { FeedbackPreRequestDetails } from '../../shared/feedback/feedback.types';
8+
9+
@Component({
10+
selector: 'app-feedback-pre-request-list',
11+
imports: [MatButtonModule, MatIconModule, MatTableModule, MatTooltipModule],
12+
templateUrl: './feedback-pre-request-list.component.html',
13+
encapsulation: ViewEncapsulation.None,
14+
})
15+
export class FeedbackPreRequestListComponent {
16+
private document = inject(DOCUMENT);
17+
18+
protected preRequestFeedbackService = inject(PreRequestFeedbackService);
19+
20+
list = input.required<FeedbackPreRequestDetails[]>();
21+
22+
protected copiedMagicLink = signal<string>('');
23+
24+
protected columns = ['expiresInHours', 'remainingUses', 'shared', 'actions'];
25+
26+
// "d" means "details"
27+
protected d(element: unknown) {
28+
return element as FeedbackPreRequestDetails;
29+
}
30+
31+
protected toClipboard(magicLink: string) {
32+
this.copiedMagicLink.set(magicLink);
33+
this.document.defaultView?.navigator.clipboard.writeText(magicLink);
34+
}
35+
}

client/src/app/request-feedback/request-feedback-success/request-feedback-success.component.html

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,14 +43,6 @@ <h1 class="gbl-info__title" i18n="@@Component.RequestFeedbackSuccess.GenerateTit
4343
>
4444
{{ state.magicLink }}
4545
</button>
46-
47-
<p
48-
class="gbl-label-small mt-1! text-balance"
49-
style="color: var(--mat-sys-on-surface-variant)"
50-
i18n="@@Component.RequestFeedbackSuccess.MagicLinkWarning"
51-
>
52-
Pensez à copier le lien magique, accessible uniquement sur cette page !
53-
</p>
5446
</div>
5547
}
5648
}

client/src/app/request-feedback/request-feedback.component.html

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,38 @@
1+
@if (magicLinkList()?.length && magicLinkList(); as list) {
2+
@if (device() !== 'mobile') {
3+
<button mat-flat-button class="float-right ms-4" (click)="openMagicLinksDialog()">
4+
<mat-icon>link</mat-icon>
5+
<ng-container i18n="@@Component.RequestFeedback.MagicLinks">Liens magiques</ng-container>
6+
</button>
7+
} @else {
8+
<button
9+
mat-mini-fab
10+
class="absolute! top-5 right-5"
11+
matTooltipPosition="left"
12+
matTooltip="Liens magiques"
13+
i18n-matTooltip="@@Component.RequestFeedback.MagicLinks"
14+
aria-label="Liens magiques"
15+
i18n-aria-label="@@Component.RequestFeedback.MagicLinks"
16+
(click)="openMagicLinksDialog()"
17+
>
18+
<mat-icon>link</mat-icon>
19+
</button>
20+
}
21+
22+
<ng-template #magicLinkDialogTmpl>
23+
<div role="heading" aria-level="2" mat-dialog-title>
24+
<mat-icon appIcon size="md" pull="left">link</mat-icon>
25+
<ng-container i18n="@@Component.RequestFeedback.MagicLinks">Liens magiques</ng-container>
26+
</div>
27+
<div mat-dialog-content>
28+
<app-feedback-pre-request-list [list]="list" />
29+
</div>
30+
<div mat-dialog-actions align="end">
31+
<button mat-button mat-dialog-close cdkFocusInitial i18n="@@Action.Close">Fermer</button>
32+
</div>
33+
</ng-template>
34+
}
35+
136
<h1 class="gbl-page-title">
237
<mat-icon>contact_support</mat-icon>
338
<ng-container i18n="@@Title.RequestFeedback">Demander du feedZback</ng-container>

client/src/app/request-feedback/request-feedback.component.ts

Lines changed: 28 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { APP_BASE_HREF } from '@angular/common';
2-
import { Component, DOCUMENT, ViewEncapsulation, inject } from '@angular/core';
3-
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
1+
import { Component, TemplateRef, ViewEncapsulation, inject, viewChild } from '@angular/core';
2+
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
43
import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
54
import { MatButtonModule } from '@angular/material/button';
5+
import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
66
import { MatFormFieldModule } from '@angular/material/form-field';
77
import { MatIconModule } from '@angular/material/icon';
88
import { MatInputModule } from '@angular/material/input';
@@ -12,14 +12,15 @@ import { MatSlideToggleModule } from '@angular/material/slide-toggle';
1212
import { MatTooltipModule } from '@angular/material/tooltip';
1313
import { ActivatedRoute, Router } from '@angular/router';
1414
import { concatMap, from, toArray } from 'rxjs';
15-
import { environment } from '../../environments/environment';
1615
import { AuthService } from '../shared/auth';
1716
import { MultiAutocompleteEmailComponent } from '../shared/autocomplete-email';
17+
import { BreakpointService } from '../shared/breakpoint';
1818
import { ConfirmBeforeSubmitDirective } from '../shared/confirm-before-submit';
1919
import { DialogTooltipDirective } from '../shared/dialog-tooltip';
20-
import { FeedbackService } from '../shared/feedback';
20+
import { FeedbackService, PreRequestFeedbackService } from '../shared/feedback';
2121
import { SMALL_MAX_LENGTH } from '../shared/feedback/feedback.config';
2222
import { FeedbackRequestDto } from '../shared/feedback/feedback.dto';
23+
import { IconDirective } from '../shared/icon';
2324
import { MessageComponent } from '../shared/message';
2425
import { StringArrayError } from '../shared/validation';
2526
import { FORBIDDEN_VALUES_KEY, forbiddenValuesValidatorFactory } from '../shared/validation/forbidden-values';
@@ -29,6 +30,7 @@ import {
2930
getMultipleEmails,
3031
multipleEmailsValidatorFactory,
3132
} from '../shared/validation/multiple-emails';
33+
import { FeedbackPreRequestListComponent } from './feedback-pre-request-list/feedback-pre-request-list.component';
3234
import { RequestFeedbackSuccess } from './request-feedback-success/request-feedback-success.types';
3335
import {
3436
FEEDBACK_PRE_REQUEST_EXPIRATION_IN_DAYS,
@@ -41,6 +43,7 @@ import {
4143
imports: [
4244
ReactiveFormsModule,
4345
MatButtonModule,
46+
MatDialogModule,
4447
MatFormFieldModule,
4548
MatIconModule,
4649
MatInputModule,
@@ -51,24 +54,34 @@ import {
5154
MultiAutocompleteEmailComponent,
5255
ConfirmBeforeSubmitDirective,
5356
DialogTooltipDirective,
57+
IconDirective,
5458
MessageComponent,
59+
FeedbackPreRequestListComponent,
5560
],
5661
templateUrl: './request-feedback.component.html',
5762
encapsulation: ViewEncapsulation.None,
5863
})
5964
export class RequestFeedbackComponent {
60-
private document = inject(DOCUMENT);
61-
62-
private appBaseHref = inject(APP_BASE_HREF);
63-
6465
private router = inject(Router);
6566

6667
private activatedRoute = inject(ActivatedRoute);
6768

6869
private formBuilder = inject(NonNullableFormBuilder);
6970

71+
private matDialog = inject(MatDialog);
72+
73+
protected device = toSignal(inject(BreakpointService).device$);
74+
7075
private feedbackService = inject(FeedbackService);
7176

77+
private preRequestFeedbackService = inject(PreRequestFeedbackService);
78+
79+
private magicLinkDialogRef?: MatDialogRef<unknown>;
80+
81+
magicLinkDialogTmpl = viewChild.required<TemplateRef<unknown>>('magicLinkDialogTmpl');
82+
83+
protected magicLinkList = toSignal(this.feedbackService.getPreRequestList()); // Note: this value is NOT reactive!
84+
7285
private recipient: string = this.activatedRoute.snapshot.queryParams['recipient'] ?? '';
7386

7487
protected messageMaxLength = SMALL_MAX_LENGTH;
@@ -191,22 +204,17 @@ export class RequestFeedbackComponent {
191204
this.feedbackService.preRequestToken({ message, shared }).subscribe(({ token }) => {
192205
this.navigateToSuccess({
193206
method: 'generate',
194-
magicLink: this.buildMagicLink(token),
207+
magicLink: this.preRequestFeedbackService.buildMagicLink(token),
195208
});
196209
});
197210
}
198211

199-
private buildMagicLink(token: string) {
200-
// IMPORTANT NOTE:
201-
// The magic link does not contain the locale.
202-
// The redirection to `/fr` or `/en` occurs when the colleague visits the magic link page (see `src/404.html` for details).
203-
// However, since this redirection is not functional in the `dev-local` environment, the locale is explicitly added only in this case.
204-
const locale = environment.alias === 'dev-local' ? this.appBaseHref : '/';
205-
206-
return `${this.document.location.origin}${locale}pre-request/token/${token}`;
207-
}
208-
209212
private navigateToSuccess(state: RequestFeedbackSuccess) {
210213
this.router.navigate(['success'], { relativeTo: this.activatedRoute, state });
211214
}
215+
216+
protected openMagicLinksDialog() {
217+
this.magicLinkDialogRef = this.matDialog.open(this.magicLinkDialogTmpl(), { width: '560px' });
218+
this.magicLinkDialogRef.afterClosed().subscribe(() => (this.magicLinkDialogRef = undefined));
219+
}
212220
}

client/src/app/request-feedback/request-feedback.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,5 @@ export const REQUEST_TEMPLATES = [
2121
},
2222
] as const;
2323

24-
export const FEEDBACK_PRE_REQUEST_EXPIRATION_IN_DAYS = 3; // (not `4` like on server-side)
24+
export const FEEDBACK_PRE_REQUEST_EXPIRATION_IN_DAYS = 3;
2525
export const FEEDBACK_PRE_REQUEST_MAX_USES = 10;

client/src/app/shared/feedback/feedback.service.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
FeedbackItem,
2020
FeedbackListMap,
2121
FeedbackListType,
22+
FeedbackPreRequestDetails,
2223
FeedbackPreRequestSummary,
2324
FeedbackRequest,
2425
FeedbackRequestDraft,
@@ -57,6 +58,10 @@ export class FeedbackService {
5758
});
5859
}
5960

61+
getPreRequestList() {
62+
return this.httpClient.get<FeedbackPreRequestDetails[]>(`${this.apiBaseUrl}/feedback/pre-request/list`);
63+
}
64+
6065
// ----- Request feedback and give requested feedback -----
6166

6267
// The cookie `app-locale-id` must be provided (using the `withCredentials` option)

0 commit comments

Comments
 (0)