From 8d084b41572d624e6df2081b5f4357954be68e59 Mon Sep 17 00:00:00 2001 From: Oleh Paduchak Date: Wed, 10 Sep 2025 16:59:45 +0300 Subject: [PATCH 1/3] feat(cookie-consent): added toast which asks for cookie consent --- src/app/app.component.html | 1 + src/app/app.component.ts | 3 +- .../cookie-consent.component.html | 30 +++++++++++++ .../cookie-consent.component.scss | 0 .../cookie-consent.component.spec.ts | 22 +++++++++ .../cookie-consent.component.ts | 45 +++++++++++++++++++ .../components/toast/toast.component.html | 2 +- .../shared/services/cookie-consent.service.ts | 18 ++++++++ src/app/shared/services/toast.service.ts | 12 +++-- src/assets/i18n/en.json | 6 +++ 10 files changed, 134 insertions(+), 5 deletions(-) create mode 100644 src/app/shared/components/cookie-consent/cookie-consent.component.html create mode 100644 src/app/shared/components/cookie-consent/cookie-consent.component.scss create mode 100644 src/app/shared/components/cookie-consent/cookie-consent.component.spec.ts create mode 100644 src/app/shared/components/cookie-consent/cookie-consent.component.ts create mode 100644 src/app/shared/services/cookie-consent.service.ts diff --git a/src/app/app.component.html b/src/app/app.component.html index 4997c5280..37294ebc1 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -1,3 +1,4 @@ + diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 983562162..9f0c3c05f 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -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, diff --git a/src/app/shared/components/cookie-consent/cookie-consent.component.html b/src/app/shared/components/cookie-consent/cookie-consent.component.html new file mode 100644 index 000000000..3d8099f4c --- /dev/null +++ b/src/app/shared/components/cookie-consent/cookie-consent.component.html @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + +
+ {{ message.detail }} +
+ +
+
+
+
diff --git a/src/app/shared/components/cookie-consent/cookie-consent.component.scss b/src/app/shared/components/cookie-consent/cookie-consent.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/shared/components/cookie-consent/cookie-consent.component.spec.ts b/src/app/shared/components/cookie-consent/cookie-consent.component.spec.ts new file mode 100644 index 000000000..4cc60fe93 --- /dev/null +++ b/src/app/shared/components/cookie-consent/cookie-consent.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { CookieConsentComponent } from './cookie-consent.component'; + +describe('CookieConsentComponent', () => { + let component: CookieConsentComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CookieConsentComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(CookieConsentComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/components/cookie-consent/cookie-consent.component.ts b/src/app/shared/components/cookie-consent/cookie-consent.component.ts new file mode 100644 index 000000000..37b1f0dc1 --- /dev/null +++ b/src/app/shared/components/cookie-consent/cookie-consent.component.ts @@ -0,0 +1,45 @@ +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.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() { + if (!this.consentService.hasConsent()) { + // Wait for translation stream to emit to avoid race conditions with async loader + this.translateService.get('toast.cookie-consent.message').subscribe((detail) => { + // Defer to next microtask to ensure p-toast view is initialized + queueMicrotask(() => + this.toastService.add({ + detail, + key: 'cookie', + sticky: true, + severity: 'warn', + closable: false, + }) + ); + }); + } + } + + acceptCookies() { + this.consentService.grantConsent(); + this.toastService.clear('cookie'); + } +} diff --git a/src/app/shared/components/toast/toast.component.html b/src/app/shared/components/toast/toast.component.html index 2edcc0abe..41fa03355 100644 --- a/src/app/shared/components/toast/toast.component.html +++ b/src/app/shared/components/toast/toast.component.html @@ -1,4 +1,4 @@ - +
{{ message.summary | translate: message.data?.translationParams }}
diff --git a/src/app/shared/services/cookie-consent.service.ts b/src/app/shared/services/cookie-consent.service.ts new file mode 100644 index 000000000..196bf75af --- /dev/null +++ b/src/app/shared/services/cookie-consent.service.ts @@ -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); + } +} diff --git a/src/app/shared/services/toast.service.ts b/src/app/shared/services/toast.service.ts index 850fdef1a..da6ac62e1 100644 --- a/src/app/shared/services/toast.service.ts +++ b/src/app/shared/services/toast.service.ts @@ -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', + }); } } diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 4b5de450a..ec69e8971 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -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", From a41b1825bc7c5508ba4148362c591d587cc4727b Mon Sep 17 00:00:00 2001 From: Oleh Paduchak Date: Wed, 10 Sep 2025 17:18:06 +0300 Subject: [PATCH 2/3] chore(cookie-consent): added tests for cookie consent --- .../cookie-consent.component.html | 16 ----- .../cookie-consent.component.spec.ts | 68 ++++++++++++++++++- .../cookie-consent.component.ts | 4 +- .../cookie-consent.service.ts | 0 .../cookie-consent/cookie-consent.spec.ts | 42 ++++++++++++ 5 files changed, 108 insertions(+), 22 deletions(-) rename src/app/shared/services/{ => cookie-consent}/cookie-consent.service.ts (100%) create mode 100644 src/app/shared/services/cookie-consent/cookie-consent.spec.ts diff --git a/src/app/shared/components/cookie-consent/cookie-consent.component.html b/src/app/shared/components/cookie-consent/cookie-consent.component.html index 3d8099f4c..a1823bba7 100644 --- a/src/app/shared/components/cookie-consent/cookie-consent.component.html +++ b/src/app/shared/components/cookie-consent/cookie-consent.component.html @@ -1,19 +1,3 @@ - - - - - - - - - - - - - - - -
diff --git a/src/app/shared/components/cookie-consent/cookie-consent.component.spec.ts b/src/app/shared/components/cookie-consent/cookie-consent.component.spec.ts index 4cc60fe93..2c982b16c 100644 --- a/src/app/shared/components/cookie-consent/cookie-consent.component.spec.ts +++ b/src/app/shared/components/cookie-consent/cookie-consent.component.spec.ts @@ -1,22 +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; + let mockToastService: jest.Mocked; + let mockConsentService: jest.Mocked; + let mockTranslateService: jest.Mocked; beforeEach(async () => { + mockToastService = { + add: jest.fn(), + clear: jest.fn(), + } as unknown as jest.Mocked; + + mockConsentService = { + hasConsent: jest.fn(), + grantConsent: jest.fn(), + } as unknown as jest.Mocked; + + mockTranslateService = { + get: jest.fn(), + } as unknown as jest.Mocked; + 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; - fixture.detectChanges(); + }); + 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(); + }); }); - it('should create', () => { - expect(component).toBeTruthy(); + describe('acceptCookies', () => { + it('should grant consent and clear toast', () => { + component.acceptCookies(); + expect(mockConsentService.grantConsent).toHaveBeenCalled(); + expect(mockToastService.clear).toHaveBeenCalledWith('cookie'); + }); }); }); diff --git a/src/app/shared/components/cookie-consent/cookie-consent.component.ts b/src/app/shared/components/cookie-consent/cookie-consent.component.ts index 37b1f0dc1..35402f655 100644 --- a/src/app/shared/components/cookie-consent/cookie-consent.component.ts +++ b/src/app/shared/components/cookie-consent/cookie-consent.component.ts @@ -6,7 +6,7 @@ import { Toast } from 'primeng/toast'; import { AfterViewInit, Component, inject } from '@angular/core'; -import { CookieConsentService } from '../../services/cookie-consent.service'; +import { CookieConsentService } from '../../services/cookie-consent/cookie-consent.service'; @Component({ selector: 'osf-cookie-consent', @@ -22,9 +22,7 @@ export class CookieConsentComponent implements AfterViewInit { ngAfterViewInit() { if (!this.consentService.hasConsent()) { - // Wait for translation stream to emit to avoid race conditions with async loader this.translateService.get('toast.cookie-consent.message').subscribe((detail) => { - // Defer to next microtask to ensure p-toast view is initialized queueMicrotask(() => this.toastService.add({ detail, diff --git a/src/app/shared/services/cookie-consent.service.ts b/src/app/shared/services/cookie-consent/cookie-consent.service.ts similarity index 100% rename from src/app/shared/services/cookie-consent.service.ts rename to src/app/shared/services/cookie-consent/cookie-consent.service.ts diff --git a/src/app/shared/services/cookie-consent/cookie-consent.spec.ts b/src/app/shared/services/cookie-consent/cookie-consent.spec.ts new file mode 100644 index 000000000..84e0aa7d2 --- /dev/null +++ b/src/app/shared/services/cookie-consent/cookie-consent.spec.ts @@ -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 = {}; + 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); + }); +}); From f80c3884cb7a8ea1cc77376ab4cabdf508bc2236 Mon Sep 17 00:00:00 2001 From: Oleh Paduchak Date: Wed, 10 Sep 2025 19:24:23 +0300 Subject: [PATCH 3/3] chore(datacite-tracker): fixed review comments --- .../cookie-consent/cookie-consent.component.html | 2 +- .../cookie-consent/cookie-consent.component.scss | 15 +++++++++++++++ ...ent.spec.ts => cookie-consent.service.spec.ts} | 0 3 files changed, 16 insertions(+), 1 deletion(-) rename src/app/shared/services/cookie-consent/{cookie-consent.spec.ts => cookie-consent.service.spec.ts} (100%) diff --git a/src/app/shared/components/cookie-consent/cookie-consent.component.html b/src/app/shared/components/cookie-consent/cookie-consent.component.html index a1823bba7..7d6f5baae 100644 --- a/src/app/shared/components/cookie-consent/cookie-consent.component.html +++ b/src/app/shared/components/cookie-consent/cookie-consent.component.html @@ -1,4 +1,4 @@ - +
{{ message.detail }} diff --git a/src/app/shared/components/cookie-consent/cookie-consent.component.scss b/src/app/shared/components/cookie-consent/cookie-consent.component.scss index e69de29bb..6f2a7c8c4 100644 --- a/src/app/shared/components/cookie-consent/cookie-consent.component.scss +++ b/src/app/shared/components/cookie-consent/cookie-consent.component.scss @@ -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%; +} diff --git a/src/app/shared/services/cookie-consent/cookie-consent.spec.ts b/src/app/shared/services/cookie-consent/cookie-consent.service.spec.ts similarity index 100% rename from src/app/shared/services/cookie-consent/cookie-consent.spec.ts rename to src/app/shared/services/cookie-consent/cookie-consent.service.spec.ts