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: 0 additions & 1 deletion src/app/app.component.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
<router-outlet />
<osf-toast></osf-toast>
<osf-cookie-consent-banner></osf-cookie-consent-banner>
<osf-full-screen-loader></osf-full-screen-loader>
3 changes: 1 addition & 2 deletions src/app/app.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import { ChangeDetectionStrategy, Component, DestroyRef, effect, inject, OnInit
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NavigationEnd, Router, RouterOutlet } from '@angular/router';

import { CookieConsentBannerComponent } from '@core/components/osf-banners/cookie-consent-banner/cookie-consent-banner.component';
import { ENVIRONMENT } from '@core/provider/environment.provider';
import { GetCurrentUser } from '@core/store/user';
import { GetEmails, UserEmailsSelectors } from '@core/store/user-emails';
Expand All @@ -22,7 +21,7 @@ import { GoogleTagManagerService } from 'angular-google-tag-manager';

@Component({
selector: 'osf-root',
imports: [RouterOutlet, ToastComponent, FullScreenLoaderComponent, CookieConsentBannerComponent],
imports: [RouterOutlet, ToastComponent, FullScreenLoaderComponent],
templateUrl: './app.component.html',
styleUrl: './app.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
<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>
@if (this.displayBanner()) {
<div class="cookie-consent-container" @fadeInOut>
<div class="w-full p-message">
<div class="grid flex-row p-message-content">
<div class="col">{{ 'toast.cookie-consent.message' | translate }}</div>
<div class="col-fixed flex justify-content-end">
<p-button
type="button"
label="{{ 'toast.cookie-consent.accept' | translate }}"
(click)="acceptCookies()"
></p-button>
</div>
</div>
</div>
</ng-template>
</p-toast>
</div>
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
:host ::ng-deep .cookie-toast {
width: 900px;
max-width: min(92vw, 960px);
left: 50% !important;
transform: translateX(-50%) !important;
}
@use "styles/mixins" as mix;

:host ::ng-deep .cookie-toast .p-toast-message {
width: 100%;
}
.cookie-consent-container {
padding: 1rem !important;
display: block !important;

.p-message {
background-color: #886d3f;
color: var(--white);
width: 100%;
border-radius: var(--p-message-border-radius, 6px);
outline-width: var(--p-message-border-width, 1px);
outline-style: solid;

:host ::ng-deep .cookie-toast .p-toast-message .p-toast-message-content {
color: #fcf8e3;
width: 100%;
.p-message-content {
display: flex;
align-items: center;
padding: var(--p-message-content-padding, 1.5rem);
gap: var(--p-message-content-gap, 0.5rem);
height: 100%;
}
}
}
Original file line number Diff line number Diff line change
@@ -1,84 +1,57 @@
import { TranslateService } from '@ngx-translate/core';
import { CookieService } from 'ngx-cookie-service';

import { MessageService } from 'primeng/api';

import { of } from 'rxjs';
import { Button } from 'primeng/button';

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

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

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

describe('CookieConsentComponent', () => {
let component: CookieConsentBannerComponent;
import { OSFTestingModule } from '@testing/osf.testing.module';

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

const cookieServiceMock = {
check: jest.fn(),
set: jest.fn(),
};

beforeEach(async () => {
mockToastService = {
add: jest.fn(),
clear: jest.fn(),
} as unknown as jest.Mocked<MessageService>;
await TestBed.configureTestingModule({
imports: [OSFTestingModule, CookieConsentBannerComponent, Button],

mockConsentService = {
hasConsent: jest.fn(),
grantConsent: jest.fn(),
} as unknown as jest.Mocked<CookieConsentService>;
providers: [{ provide: CookieService, useValue: cookieServiceMock }],
});

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

await TestBed.configureTestingModule({
imports: [CookieConsentBannerComponent],
providers: [
{ provide: MessageService, useValue: mockToastService },
{ provide: CookieConsentService, useValue: mockConsentService },
{ provide: TranslateService, useValue: mockTranslateService },
],
}).compileComponents();
it('should show the banner if cookie is not set', () => {
cookieServiceMock.check.mockReturnValue(false);
fixture = TestBed.createComponent(CookieConsentBannerComponent);
component = fixture.componentInstance;

expect(component.displayBanner()).toBe(true);
});

it('should hide the banner if cookie is set', () => {
cookieServiceMock.check.mockReturnValue(true);
fixture = TestBed.createComponent(CookieConsentBannerComponent);
component = fixture.componentInstance;

expect(component.displayBanner()).toBe(false);
});
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);
it('should set cookie and hide banner on acceptCookies()', () => {
cookieServiceMock.check.mockReturnValue(false);
fixture = TestBed.createComponent(CookieConsentBannerComponent);
component = fixture.componentInstance;

component.ngAfterViewInit();
component.acceptCookies();

expect(mockTranslateService.get).not.toHaveBeenCalled();
expect(mockToastService.add).not.toHaveBeenCalled();
});
});
expect(cookieServiceMock.set).toHaveBeenCalledWith('cookie-consent', 'true', new Date('9999-12-31T23:59:59Z'), '/');

describe('acceptCookies', () => {
it('should grant consent and clear toast', () => {
component.acceptCookies();
expect(mockConsentService.grantConsent).toHaveBeenCalled();
expect(mockToastService.clear).toHaveBeenCalledWith('cookie');
});
expect(component.displayBanner()).toBe(false);
});
});
Original file line number Diff line number Diff line change
@@ -1,42 +1,61 @@
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { CookieService } from 'ngx-cookie-service';
import { TranslatePipe } 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 { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';

import { CookieConsentService } from '../../../../shared/services/cookie-consent/cookie-consent.service';
import { fadeInOutAnimation } from '@core/animations/fade.in-out.animation';

/**
* Displays a cookie consent banner until the user accepts.
*
* - Uses `ngx-cookie-service` to persist acceptance across sessions.
* - Automatically hides the banner if consent is already recorded.
* - Animates in/out using the `fadeInOutAnimation`.
* - Supports translation via `TranslatePipe`.
*/
@Component({
selector: 'osf-cookie-consent-banner',
templateUrl: './cookie-consent-banner.component.html',
styleUrls: ['./cookie-consent-banner.component.scss'],
imports: [Toast, Button, PrimeTemplate, TranslatePipe],
imports: [Button, TranslatePipe],
animations: [fadeInOutAnimation],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CookieConsentBannerComponent implements AfterViewInit {
private readonly toastService = inject(MessageService);
private readonly consentService = inject(CookieConsentService);
private readonly translateService = inject(TranslateService);

ngAfterViewInit() {
if (!this.consentService.hasConsent()) {
this.translateService.get('toast.cookie-consent.message').subscribe((detail) => {
queueMicrotask(() =>
this.toastService.add({
detail,
key: 'cookie',
sticky: true,
severity: 'warn',
closable: false,
})
);
});
}
export class CookieConsentBannerComponent {
/**
* The name of the cookie used to track whether the user accepted cookies.
*/
private readonly cookieName = 'cookie-consent';

/**
* Signal controlling the visibility of the cookie banner.
* Set to `true` if the user has not accepted cookies yet.
*/
readonly displayBanner = signal<boolean>(false);

/**
* Cookie service used to persist dismissal state in the browser.
*/
private readonly cookies = inject(CookieService);

/**
* Initializes the component and sets the banner display
* based on the existence of the cookie.
*/
constructor() {
this.displayBanner.set(!this.cookies.check(this.cookieName));
}

/**
* Called when the user accepts cookies.
* - Sets a persistent cookie with a far-future expiration.
* - Hides the banner immediately.
*/
acceptCookies() {
this.consentService.grantConsent();
this.toastService.clear('cookie');
const expireDate = new Date('9999-12-31T23:59:59Z');
this.cookies.set(this.cookieName, 'true', expireDate, '/');
this.displayBanner.set(false);
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
<osf-maintenance-banner></osf-maintenance-banner>
<osf-scheduled-banner></osf-scheduled-banner>
<osf-maintenance-banner></osf-maintenance-banner>
<osf-cookie-consent-banner></osf-cookie-consent-banner>
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.w-full {
height: 108px !important;
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { BannersSelector, GetCurrentScheduledBanner } from '@osf/shared/stores/b
@Component({
selector: 'osf-scheduled-banner',
templateUrl: './scheduled-banner.component.html',
styleUrls: ['./scheduled-banner.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ScheduledBannerComponent implements OnInit {
Expand Down
2 changes: 1 addition & 1 deletion src/app/core/components/root/root.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { HeaderComponent } from '@core/components/header/header.component';
import { TopnavComponent } from '@core/components/topnav/topnav.component';
import { IS_WEB, IS_XSMALL } from '@osf/shared/helpers';

import { OSFBannerComponent } from '../osf-banners/osf.banner.component';
import { OSFBannerComponent } from '../osf-banners/osf-banner.component';
import { SidenavComponent } from '../sidenav/sidenav.component';

import { RootComponent } from './root.component';
Expand Down

This file was deleted.

This file was deleted.

18 changes: 0 additions & 18 deletions src/app/shared/services/cookie-consent/cookie-consent.service.ts

This file was deleted.

Loading