Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/app/app.component.html
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
<router-outlet />
<osf-toast></osf-toast>
<osf-cookie-consent></osf-cookie-consent>
<osf-full-screen-loader></osf-full-screen-loader>
3 changes: 2 additions & 1 deletion src/app/app.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@ import { Router, RouterOutlet } from '@angular/router';
import { GetCurrentUser } from '@core/store/user';
import { GetEmails, UserEmailsSelectors } from '@core/store/user-emails';
import { ConfirmEmailComponent } from '@shared/components';
import { CookieConsentComponent } from '@shared/components/cookie-consent/cookie-consent.component';

import { FullScreenLoaderComponent, ToastComponent } from './shared/components';

@Component({
selector: 'osf-root',
imports: [RouterOutlet, ToastComponent, FullScreenLoaderComponent],
imports: [RouterOutlet, ToastComponent, FullScreenLoaderComponent, CookieConsentComponent],
templateUrl: './app.component.html',
styleUrl: './app.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
Expand Down
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will display every time. I think it needs an @if

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This won't be displayed every time, toasts are diplayed only when MessageService is invoked

Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<p-toast key="cookie" position="bottom-center" [styleClass]="'cookie-toast'">
<ng-template let-message pTemplate="message">
<div class="flex flex-column gap-2">
<span>{{ message.detail }}</span>
<div class="flex justify-content-end">
<p-button
type="button"
label="{{ 'toast.cookie-consent.accept' | translate }}"
(click)="acceptCookies()"
></p-button>
</div>
</div>
</ng-template>
</p-toast>
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
:host ::ng-deep .cookie-toast {
width: 900px;
max-width: min(92vw, 960px);
left: 50% !important;
transform: translateX(-50%) !important;
}

:host ::ng-deep .cookie-toast .p-toast-message {
width: 100%;
}

:host ::ng-deep .cookie-toast .p-toast-message .p-toast-message-content {
color: #fcf8e3;
width: 100%;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { TranslateService } from '@ngx-translate/core';

import { MessageService } from 'primeng/api';

import { of } from 'rxjs';

import { ComponentFixture, TestBed } from '@angular/core/testing';

import { CookieConsentService } from '../../services/cookie-consent/cookie-consent.service';

import { CookieConsentComponent } from './cookie-consent.component';

describe('CookieConsentComponent', () => {
let component: CookieConsentComponent;
let fixture: ComponentFixture<CookieConsentComponent>;
let mockToastService: jest.Mocked<MessageService>;
let mockConsentService: jest.Mocked<CookieConsentService>;
let mockTranslateService: jest.Mocked<TranslateService>;

beforeEach(async () => {
mockToastService = {
add: jest.fn(),
clear: jest.fn(),
} as unknown as jest.Mocked<MessageService>;

mockConsentService = {
hasConsent: jest.fn(),
grantConsent: jest.fn(),
} as unknown as jest.Mocked<CookieConsentService>;

mockTranslateService = {
get: jest.fn(),
} as unknown as jest.Mocked<TranslateService>;

await TestBed.configureTestingModule({
imports: [CookieConsentComponent],
providers: [
{ provide: MessageService, useValue: mockToastService },
{ provide: CookieConsentService, useValue: mockConsentService },
{ provide: TranslateService, useValue: mockTranslateService },
],
}).compileComponents();

fixture = TestBed.createComponent(CookieConsentComponent);
component = fixture.componentInstance;
});
describe('ngAfterViewInit', () => {
it('should show toast if no consent', () => {
mockConsentService.hasConsent.mockReturnValue(false);
mockTranslateService.get.mockReturnValue(of('Please accept cookies'));

component.ngAfterViewInit();

// wait for queueMicrotask to execute
return Promise.resolve().then(() => {
expect(mockTranslateService.get).toHaveBeenCalledWith('toast.cookie-consent.message');
expect(mockToastService.add).toHaveBeenCalledWith({
detail: 'Please accept cookies',
key: 'cookie',
sticky: true,
severity: 'warn',
closable: false,
});
});
});

it('should not show toast if consent already given', () => {
mockConsentService.hasConsent.mockReturnValue(true);

component.ngAfterViewInit();

expect(mockTranslateService.get).not.toHaveBeenCalled();
expect(mockToastService.add).not.toHaveBeenCalled();
});
});

describe('acceptCookies', () => {
it('should grant consent and clear toast', () => {
component.acceptCookies();
expect(mockConsentService.grantConsent).toHaveBeenCalled();
expect(mockToastService.clear).toHaveBeenCalledWith('cookie');
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { TranslateModule, TranslateService } from '@ngx-translate/core';

import { MessageService, PrimeTemplate } from 'primeng/api';
import { Button } from 'primeng/button';
import { Toast } from 'primeng/toast';

import { AfterViewInit, Component, inject } from '@angular/core';

import { CookieConsentService } from '../../services/cookie-consent/cookie-consent.service';

@Component({
selector: 'osf-cookie-consent',
standalone: true,
templateUrl: './cookie-consent.component.html',
styleUrls: ['./cookie-consent.component.scss'],
imports: [Toast, Button, PrimeTemplate, TranslateModule],
})
export class CookieConsentComponent implements AfterViewInit {
private readonly toastService = inject(MessageService);
private readonly consentService = inject(CookieConsentService);
private readonly translateService = inject(TranslateService);

ngAfterViewInit() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this in ngAfterViewInit()?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both this and microtask are needed because otherwise toast service will be invoked before view is initialized, and therefore won't be displayed.

if (!this.consentService.hasConsent()) {
this.translateService.get('toast.cookie-consent.message').subscribe((detail) => {
queueMicrotask(() =>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you need a microtask?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both this and ngAfterViewInit are needed because otherwise toast service will be invoked before view is initialized, and therefore won't be displayed.

this.toastService.add({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you need a banner and toast?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I don't understand. This PR displays only only the toast.

detail,
key: 'cookie',
sticky: true,
severity: 'warn',
closable: false,
})
);
});
}
}

acceptCookies() {
this.consentService.grantConsent();
this.toastService.clear('cookie');
}
}
2 changes: 1 addition & 1 deletion src/app/shared/components/toast/toast.component.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<p-toast class="toast-container" position="top-right" [preventOpenDuplicates]="true">
<p-toast class="toast-container" position="top-right" [preventOpenDuplicates]="true" key="osf">
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why did you need to add a "key"? And what does it do?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

key attribute differentiates between default toast and cookie consent toast, which must be different because cookie consent toast contains a button, and default one does not

<ng-template let-message pTemplate="message">
<div class="font-medium w-full">{{ message.summary | translate: message.data?.translationParams }}</div>
</ng-template>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { TestBed } from '@angular/core/testing';

import { CookieConsentService } from './cookie-consent.service';

describe('CookieConsentService', () => {
let service: CookieConsentService;

beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(CookieConsentService);

const store: Record<string, string> = {};
jest.spyOn(localStorage, 'getItem').mockImplementation((key: string) => store[key] || null);
jest.spyOn(localStorage, 'setItem').mockImplementation((key: string, value: string) => {
store[key] = value;
});
jest.spyOn(localStorage, 'removeItem').mockImplementation((key: string) => {
delete store[key];
});
});

afterEach(() => {
jest.restoreAllMocks();
});

it('should return false if no consent is stored', () => {
expect(service.hasConsent()).toBe(false);
});

it('should return true after consent is granted', () => {
service.grantConsent();
expect(service.hasConsent()).toBe(true);
});

it('should remove consent when revoked', () => {
service.grantConsent();
expect(service.hasConsent()).toBe(true);

service.revokeConsent();
expect(service.hasConsent()).toBe(false);
});
});
18 changes: 18 additions & 0 deletions src/app/shared/services/cookie-consent/cookie-consent.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Injectable } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class CookieConsentService {
private consentKey = 'cookie-consent';

hasConsent(): boolean {
return localStorage.getItem(this.consentKey) === 'true';
}

grantConsent() {
localStorage.setItem(this.consentKey, 'true');
}

revokeConsent() {
localStorage.removeItem(this.consentKey);
}
}
12 changes: 9 additions & 3 deletions src/app/shared/services/toast.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,20 @@ export class ToastService {
private messageService = inject(MessageService);

showSuccess(summary: string, params?: unknown) {
this.messageService.add({ severity: 'success', summary, data: { translationParams: params } });
this.messageService.add({ severity: 'success', summary, data: { translationParams: params }, key: 'osf' });
}

showWarn(summary: string, params?: unknown) {
this.messageService.add({ severity: 'warn', summary, data: { translationParams: params } });
this.messageService.add({ severity: 'warn', summary, data: { translationParams: params }, key: 'osf' });
}

showError(summary: string, params?: unknown) {
this.messageService.add({ severity: 'error', summary, life: 5000, data: { translationParams: params } });
this.messageService.add({
severity: 'error',
summary,
life: 5000,
data: { translationParams: params },
key: 'osf',
});
}
}
6 changes: 6 additions & 0 deletions src/assets/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,12 @@
"links": "Links",
"institutions": "Institutions"
},
"toast": {
"cookie-consent": {
"message": "Notice: This website relies on cookies to help provide a better user experience. By clicking accept or continuing to use the site, you consent to our use of cookies. See our Privacy Policy and Cookie Use for more information.",
"accept": "Accept cookies"
}
},
"auth": {
"common": {
"email": "Email",
Expand Down