From 64119ac2d84519a8adc47746b915528cf5d30964 Mon Sep 17 00:00:00 2001 From: Brian Pilati Date: Fri, 19 Sep 2025 13:23:14 -0500 Subject: [PATCH 1/6] chore(merge conflicts): fixed merge conflicts - 2 --- src/app/core/components/index.ts | 2 +- .../maintenance-banner.component.html | 13 -- .../maintenance-banner.component.scss | 0 .../maintenance-banner.component.spec.ts | 150 ------------------ .../maintenance-banner.component.ts | 100 ------------ .../scheduled-banner.component.html | 16 -- .../scheduled-banner.component.spec.ts | 144 ----------------- .../scheduled-banner.component.ts | 69 -------- .../core/components/root/root.component.ts | 2 +- src/app/shared/components/index.ts | 1 + 10 files changed, 3 insertions(+), 494 deletions(-) delete mode 100644 src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.html delete mode 100644 src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.scss delete mode 100644 src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.spec.ts delete mode 100644 src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.ts delete mode 100644 src/app/core/components/osf-banners/scheduled-banner/scheduled-banner.component.html delete mode 100644 src/app/core/components/osf-banners/scheduled-banner/scheduled-banner.component.spec.ts delete mode 100644 src/app/core/components/osf-banners/scheduled-banner/scheduled-banner.component.ts diff --git a/src/app/core/components/index.ts b/src/app/core/components/index.ts index db8885c36..a7def29b1 100644 --- a/src/app/core/components/index.ts +++ b/src/app/core/components/index.ts @@ -1,7 +1,7 @@ export { BreadcrumbComponent } from './breadcrumb/breadcrumb.component'; export { FooterComponent } from './footer/footer.component'; export { HeaderComponent } from './header/header.component'; -export { MaintenanceBannerComponent } from './osf-banners/maintenance-banner/maintenance-banner.component'; +export { MaintenanceBannerComponent } from './osf-banners/maintenance-banner/maintenance.banner.component'; export { PageNotFoundComponent } from './page-not-found/page-not-found.component'; export { RootComponent } from './root/root.component'; export { SidenavComponent } from './sidenav/sidenav.component'; diff --git a/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.html b/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.html deleted file mode 100644 index a936ebefc..000000000 --- a/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.html +++ /dev/null @@ -1,13 +0,0 @@ -@if (maintenance() && !dismissed()) { - - -} diff --git a/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.scss b/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.scss deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.spec.ts b/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.spec.ts deleted file mode 100644 index 1711da79c..000000000 --- a/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.spec.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { CookieService } from 'ngx-cookie-service'; - -import { MessageModule } from 'primeng/message'; - -import { of } from 'rxjs'; - -import { HttpClient } from '@angular/common/http'; -import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; -import { By } from '@angular/platform-browser'; -import { NoopAnimationsModule } from '@angular/platform-browser/animations'; - -import { MaintenanceBannerComponent } from './maintenance-banner.component'; - -describe('Component: Maintenance Banner', () => { - let fixture: ComponentFixture; - let httpClient: { get: jest.Mock }; - let cookieService: jest.Mocked; - - beforeEach(async () => { - cookieService = { - check: jest.fn(), - set: jest.fn(), - } as any; - httpClient = { get: jest.fn() } as any; - await TestBed.configureTestingModule({ - imports: [MaintenanceBannerComponent, NoopAnimationsModule, MessageModule], - providers: [ - { provide: CookieService, useValue: cookieService }, - { provide: HttpClient, useValue: httpClient }, - ], - }).compileComponents(); - - fixture = TestBed.createComponent(MaintenanceBannerComponent); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('should render info banner when maintenance data is present', fakeAsync(() => { - cookieService.check.mockReturnValue(false); - const now = new Date(); - const start = new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString(); - const end = new Date(now.getTime() + 24 * 60 * 60 * 1000).toISOString(); - httpClient.get.mockReturnValueOnce( - of({ - maintenance: { level: 1, message: 'Info message', start, end }, - }) - ); - fixture.detectChanges(); - tick(); - fixture.detectChanges(); - const banner = fixture.debugElement.query(By.css('p-message')); - expect(banner).toBeTruthy(); - expect(banner.componentInstance.severity).toBe('info'); - expect(banner.nativeElement.textContent).toContain('Info message'); - })); - - it('should render warning banner when level is 2', fakeAsync(() => { - cookieService.check.mockReturnValue(false); - const now = new Date(); - const start = new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString(); - const end = new Date(now.getTime() + 24 * 60 * 60 * 1000).toISOString(); - httpClient.get.mockReturnValueOnce( - of({ - maintenance: { - level: 2, - message: 'Warning message', - start, - end, - }, - }) - ); - fixture.detectChanges(); - tick(); - fixture.detectChanges(); - const banner = fixture.debugElement.query(By.css('p-message')); - expect(banner).toBeTruthy(); - expect(banner.componentInstance.severity).toBe('warn'); - expect(banner.nativeElement.textContent).toContain('Warning message'); - })); - - it('should render danger banner when level is 3', fakeAsync(() => { - cookieService.check.mockReturnValue(false); - const now = new Date(); - const start = new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString(); - const end = new Date(now.getTime() + 24 * 60 * 60 * 1000).toISOString(); - httpClient.get.mockReturnValueOnce( - of({ - maintenance: { - level: 3, - message: 'Danger message', - start, - end, - }, - }) - ); - fixture.detectChanges(); - tick(); - fixture.detectChanges(); - const banner = fixture.debugElement.query(By.css('p-message')); - expect(banner).toBeTruthy(); - expect(banner.componentInstance.severity).toBe('error'); - expect(banner.nativeElement.textContent).toContain('Danger message'); - })); - - it('should not render banner if cookie is set', fakeAsync(() => { - cookieService.check.mockReturnValue(true); - fixture.detectChanges(); - expect(httpClient.get).not.toHaveBeenCalled(); - fixture.detectChanges(); - const banner = fixture.debugElement.query(By.css('p-message')); - expect(banner).toBeFalsy(); - })); - - it('should not render banner if outside maintenance window', fakeAsync(() => { - cookieService.check.mockReturnValue(false); - httpClient.get.mockReturnValueOnce( - of({ - maintenance: { level: 1, message: 'Old message', start: '2020-01-01T00:00:00Z', end: '2020-01-02T00:00:00Z' }, - }) - ); - fixture.detectChanges(); - tick(); - fixture.detectChanges(); - const banner = fixture.debugElement.query(By.css('p-message')); - expect(banner).toBeFalsy(); - })); - - it('should dismiss banner when close button is clicked', fakeAsync(() => { - cookieService.check.mockReturnValue(false); - const now = new Date(); - const start = new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString(); - const end = new Date(now.getTime() + 24 * 60 * 60 * 1000).toISOString(); - httpClient.get.mockReturnValueOnce( - of({ - maintenance: { level: 1, message: 'Dismiss me', start, end }, - }) - ); - fixture.detectChanges(); - tick(); - fixture.detectChanges(); - const banner = fixture.debugElement.query(By.css('p-message')); - expect(banner).toBeTruthy(); - banner.triggerEventHandler('onClose', {}); - fixture.detectChanges(); - expect(fixture.debugElement.query(By.css('p-message'))).toBeFalsy(); - expect(cookieService.set).toHaveBeenCalled(); - })); -}); diff --git a/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.ts b/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.ts deleted file mode 100644 index 2632ce03d..000000000 --- a/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { CookieService } from 'ngx-cookie-service'; - -import { MessageModule } from 'primeng/message'; - -import { CommonModule } from '@angular/common'; -import { ChangeDetectionStrategy, Component, inject, OnInit, signal } from '@angular/core'; - -import { fadeInOutAnimation } from '@core/animations/fade.in-out.animation'; - -import { MaintenanceModel } from '../models/maintenance.model'; -import { MaintenanceService } from '../services/maintenance.service'; - -/** - * A banner component that displays a scheduled maintenance message to users. - * - * This component checks a cookie to determine whether the user has previously dismissed - * the banner. If not, it queries the maintenance status from the server and displays - * the maintenance message if one is active. - * - * The component supports animation via `fadeInOutAnimation` and is optimized with `OnPush` change detection. - * - * @example - * ```html - * - * ``` - */ -@Component({ - selector: 'osf-maintenance-banner', - imports: [CommonModule, MessageModule], - templateUrl: './maintenance-banner.component.html', - styleUrls: ['./maintenance-banner.component.scss'], - animations: [fadeInOutAnimation], - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class MaintenanceBannerComponent implements OnInit { - /** - * Signal to track whether the user has dismissed the banner. - */ - dismissed = signal(false); - - /** - * Signal that holds the current maintenance status fetched from the server. - */ - maintenance = signal(null); - - /** - * Service used to fetch maintenance status from the backend. - */ - private readonly maintenanceService = inject(MaintenanceService); - - /** - * Cookie service used to persist dismissal state in the browser. - */ - private readonly cookies = inject(CookieService); - - /** - * The cookie name used to store whether the user dismissed the banner. - */ - private readonly cookieName = 'osf-maintenance-dismissed'; - - /** - * Duration (in hours) to persist the dismissal cookie. - */ - private readonly cookieDurationHours = 24; - - /** - * Lifecycle hook that initializes the component: - * - Checks if dismissal cookie exists and sets `dismissed` - * - If not dismissed, triggers a fetch of current maintenance status - */ - ngOnInit(): void { - this.dismissed.set(this.cookies.check(this.cookieName)); - if (!this.dismissed()) { - this.fetchMaintenanceStatus(); - } - } - - /** - * Fetches the current maintenance status from the backend via the `MaintenanceService` - * and sets it to the `maintenance` signal. - * - * If no maintenance is active, the signal remains `null`. - */ - private fetchMaintenanceStatus(): void { - this.maintenanceService.fetchMaintenanceStatus().subscribe((maintenance: MaintenanceModel | null) => { - this.maintenance.set(maintenance); - }); - } - - /** - * Dismisses the banner: - * - Sets a cookie to remember dismissal for 24 hours - * - Updates the `dismissed` and `maintenance` signals - */ - dismiss(): void { - this.cookies.set(this.cookieName, '1', this.cookieDurationHours, '/'); - this.dismissed.set(true); - this.maintenance.set(null); - } -} diff --git a/src/app/core/components/osf-banners/scheduled-banner/scheduled-banner.component.html b/src/app/core/components/osf-banners/scheduled-banner/scheduled-banner.component.html deleted file mode 100644 index cd7b9d40c..000000000 --- a/src/app/core/components/osf-banners/scheduled-banner/scheduled-banner.component.html +++ /dev/null @@ -1,16 +0,0 @@ -@if (shouldShowBanner()) { -
- - - -
-} diff --git a/src/app/core/components/osf-banners/scheduled-banner/scheduled-banner.component.spec.ts b/src/app/core/components/osf-banners/scheduled-banner/scheduled-banner.component.spec.ts deleted file mode 100644 index 7379b2013..000000000 --- a/src/app/core/components/osf-banners/scheduled-banner/scheduled-banner.component.spec.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { Store } from '@ngxs/store'; - -import { BehaviorSubject } from 'rxjs'; - -import { signal, WritableSignal } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { BannerModel } from '@core/components/osf-banners/models/banner.model'; -import { IS_XSMALL } from '@osf/shared/helpers'; -import { BannersSelector, GetCurrentScheduledBanner } from '@osf/shared/stores/banners'; - -import { ScheduledBannerComponent } from './scheduled-banner.component'; - -import { provideMockStore } from '@testing/providers/store-provider.mock'; - -describe('Component: Scheduled Banner', () => { - let fixture: ComponentFixture; - let store: Store; - let component: ScheduledBannerComponent; - - const currentBannerSignal: WritableSignal = signal(null); - let isMobile$: BehaviorSubject; - - beforeEach(() => { - isMobile$ = new BehaviorSubject(false); - - TestBed.configureTestingModule({ - imports: [ScheduledBannerComponent], - providers: [ - { - provide: IS_XSMALL, - useValue: isMobile$, - }, - ], - }).overrideProvider(Store, { - useValue: provideMockStore({ - signals: [{ selector: BannersSelector.getCurrentBanner, value: currentBannerSignal }], - actions: [{ action: new GetCurrentScheduledBanner(), value: true }], - }).useValue, - }); - - fixture = TestBed.createComponent(ScheduledBannerComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - - store = TestBed.inject(Store); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('should create the component', () => { - expect(component).toBeInstanceOf(ScheduledBannerComponent); - }); - - it('should dispatch FetchCurrentScheduledBanner on init', () => { - const dispatchSpy = jest.spyOn(store, 'dispatch'); - component.ngOnInit(); - expect(dispatchSpy).toHaveBeenCalledWith(new GetCurrentScheduledBanner()); - }); - - it('should return false if no banner is set', () => { - currentBannerSignal.set(null); - expect(component.shouldShowBanner()).toBe(false); - }); - - it('should return true if current time is within banner window', () => { - const showBanner = component.shouldShowBanner; - expect(showBanner()).toBeFalsy(); - - const now = new Date(); - currentBannerSignal.set( - Object({ - color: '#123456', - link: '', - defaultPhoto: '', - defaultAltText: '', - mobilePhoto: '', - mobileAltText: '', - startDate: new Date(now.getTime() - 5000), - endDate: new Date(now.getTime() + 5000), - }) - ); - - fixture.detectChanges(); - expect(showBanner()).toBeTruthy(); - const parent = fixture.nativeElement.querySelector('[data-test-banner-parent]'); - const link = fixture.nativeElement.querySelector('[data-test-banner-href]'); - expect(parent.style.backgroundColor).toBe('rgb(18, 52, 86)'); // hex to rgb - expect(link.getAttribute('href')).toBe(''); - }); - - it('should return false if current time is outside the banner window', () => { - const now = new Date(); - currentBannerSignal.set( - Object({ - startDate: new Date(now.getTime() - 2000), - endDate: new Date(now.getTime() - 1000), - }) - ); - fixture.detectChanges(); - const parent = fixture.nativeElement.querySelector('[data-test-banner-parent]'); - const link = fixture.nativeElement.querySelector('[data-test-banner-href]'); - - expect(component.shouldShowBanner()).toBe(false); - expect(parent).toBeNull(); - expect(link).toBeNull(); - }); - - it('should reflect the isMobile signal', () => { - const now = new Date(); - currentBannerSignal.set( - Object({ - color: '#123456', - link: '', - defaultPhoto: 'default.jpg', - defaultAltText: 'default alt', - mobilePhoto: 'mobile.jpg', - mobileAltText: 'mobile alt', - startDate: new Date(now.getTime() - 5000), - endDate: new Date(now.getTime() + 5000), - }) - ); - fixture.detectChanges(); - const image = fixture.nativeElement.querySelector('[data-test-banner-image]'); - - expect(image.getAttribute('src')).toBe('default.jpg'); - expect(image.getAttribute('alt')).toBe('default alt'); - expect(component.isMobile()).toBe(false); - - isMobile$.next(true); - fixture.detectChanges(); - expect(image.getAttribute('src')).toBe('mobile.jpg'); - expect(image.getAttribute('alt')).toBe('mobile alt'); - expect(component.isMobile()).toBe(true); - - isMobile$.next(false); - fixture.detectChanges(); - expect(image.getAttribute('src')).toBe('default.jpg'); - expect(image.getAttribute('alt')).toBe('default alt'); - expect(component.isMobile()).toBe(false); - }); -}); diff --git a/src/app/core/components/osf-banners/scheduled-banner/scheduled-banner.component.ts b/src/app/core/components/osf-banners/scheduled-banner/scheduled-banner.component.ts deleted file mode 100644 index f2d8c4630..000000000 --- a/src/app/core/components/osf-banners/scheduled-banner/scheduled-banner.component.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { Store } from '@ngxs/store'; - -import { ChangeDetectionStrategy, Component, computed, inject, OnInit } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; - -import { IS_XSMALL } from '@osf/shared/helpers'; -import { BannersSelector, GetCurrentScheduledBanner } from '@osf/shared/stores/banners'; - -/** - * Component to display a scheduled banner when one is active and visible - * based on the current time and the banner's scheduled window. - * - * This component is intended to show time-based promotional or informational banners - * fetched from the store via NGXS and displayed responsively on desktop or mobile. - * - * - Uses `ChangeDetectionStrategy.OnPush` for performance. - * - Observes the current screen size to adjust image and layout responsively. - */ -@Component({ - selector: 'osf-scheduled-banner', - templateUrl: './scheduled-banner.component.html', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class ScheduledBannerComponent implements OnInit { - /** - * Injected NGXS Store instance used to dispatch actions and select state. - */ - private readonly store = inject(Store); - - /** - * Reactive signal of the currently scheduled banner fetched from the NGXS store. - */ - currentBanner = this.store.selectSignal(BannersSelector.getCurrentBanner); - - /** - * Signal representing whether the screen size is classified as 'extra small'. - * Used to conditionally render mobile-optimized images or styles. - */ - isMobile = toSignal(inject(IS_XSMALL)); - - /** - * Lifecycle hook that dispatches a store action to fetch the scheduled banner - * when the component initializes. - */ - ngOnInit(): void { - this.store.dispatch(new GetCurrentScheduledBanner()); - } - - /** - * A computed signal that determines if the current banner should be shown. - * - * @returns `true` if: - * - A banner exists - * - The current time is within the start and end time window of the banner - * - * Otherwise returns `false`. - */ - shouldShowBanner = computed(() => { - const banner = this.currentBanner(); - if (banner) { - const bannerStartTime = banner.startDate; - const bannerEndTime = banner.endDate; - const currentTime = new Date(); - - return bannerStartTime < currentTime && bannerEndTime > currentTime; - } - return false; - }); -} diff --git a/src/app/core/components/root/root.component.ts b/src/app/core/components/root/root.component.ts index 81b867196..0f7d00c9b 100644 --- a/src/app/core/components/root/root.component.ts +++ b/src/app/core/components/root/root.component.ts @@ -13,7 +13,7 @@ import { IS_MEDIUM, IS_WEB } from '@osf/shared/helpers'; import { BreadcrumbComponent } from '../breadcrumb/breadcrumb.component'; import { FooterComponent } from '../footer/footer.component'; import { HeaderComponent } from '../header/header.component'; -import { OSFBannerComponent } from '../osf-banners/osf-banner.component'; +import { OSFBannerComponent } from '../osf-banners/osf.banner.component'; import { SidenavComponent } from '../sidenav/sidenav.component'; import { TopnavComponent } from '../topnav/topnav.component'; diff --git a/src/app/shared/components/index.ts b/src/app/shared/components/index.ts index a84985ba6..c174578ca 100644 --- a/src/app/shared/components/index.ts +++ b/src/app/shared/components/index.ts @@ -1,3 +1,4 @@ +export { CookieConsentBannerComponent as CookieConsentComponent } from '../../core/components/osf-banners/cookie-consent-banner/cookie-consent.banner.component'; export { AddProjectFormComponent } from './add-project-form/add-project-form.component'; export { AffiliatedInstitutionSelectComponent } from './affiliated-institution-select/affiliated-institution-select.component'; export { AffiliatedInstitutionsViewComponent } from './affiliated-institutions-view/affiliated-institutions-view.component'; From e4c98f401ac7f4da443a39c939fcf8473712571d Mon Sep 17 00:00:00 2001 From: Brian Pilati Date: Fri, 19 Sep 2025 09:28:04 -0500 Subject: [PATCH 2/6] chore(renames): renamed files part 2 --- .../cookie-consent.banner.component.html | 14 ++ .../cookie-consent.banner.component.scss | 15 ++ .../cookie-consent.banner.component.spec.ts | 84 ++++++++++ .../cookie-consent.banner.component.ts | 42 +++++ .../maintenance.banner.component.html | 13 ++ .../maintenance.banner.component.scss | 0 .../maintenance.banner.component.spec.ts | 150 ++++++++++++++++++ .../maintenance.banner.component.ts | 100 ++++++++++++ .../osf-banners/osf.banner.component.html | 2 + .../osf-banners/osf.banner.component.spec.ts | 33 ++++ .../osf-banners/osf.banner.component.ts | 27 ++++ .../scheduled.banner.component.html | 16 ++ .../scheduled.banner.component.spec.ts | 144 +++++++++++++++++ .../scheduled.banner.component.ts | 69 ++++++++ 14 files changed, 709 insertions(+) create mode 100644 src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent.banner.component.html create mode 100644 src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent.banner.component.scss create mode 100644 src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent.banner.component.spec.ts create mode 100644 src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent.banner.component.ts create mode 100644 src/app/core/components/osf-banners/maintenance-banner/maintenance.banner.component.html create mode 100644 src/app/core/components/osf-banners/maintenance-banner/maintenance.banner.component.scss create mode 100644 src/app/core/components/osf-banners/maintenance-banner/maintenance.banner.component.spec.ts create mode 100644 src/app/core/components/osf-banners/maintenance-banner/maintenance.banner.component.ts create mode 100644 src/app/core/components/osf-banners/osf.banner.component.html create mode 100644 src/app/core/components/osf-banners/osf.banner.component.spec.ts create mode 100644 src/app/core/components/osf-banners/osf.banner.component.ts create mode 100644 src/app/core/components/osf-banners/scheduled-banner/scheduled.banner.component.html create mode 100644 src/app/core/components/osf-banners/scheduled-banner/scheduled.banner.component.spec.ts create mode 100644 src/app/core/components/osf-banners/scheduled-banner/scheduled.banner.component.ts diff --git a/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent.banner.component.html b/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent.banner.component.html new file mode 100644 index 000000000..7d6f5baae --- /dev/null +++ b/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent.banner.component.html @@ -0,0 +1,14 @@ + + +
+ {{ message.detail }} +
+ +
+
+
+
diff --git a/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent.banner.component.scss b/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent.banner.component.scss new file mode 100644 index 000000000..6f2a7c8c4 --- /dev/null +++ b/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent.banner.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/core/components/osf-banners/cookie-consent-banner/cookie-consent.banner.component.spec.ts b/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent.banner.component.spec.ts new file mode 100644 index 000000000..6300716f4 --- /dev/null +++ b/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent.banner.component.spec.ts @@ -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 '../../../../shared/services/cookie-consent/cookie-consent.service'; + +import { CookieConsentBannerComponent } from './cookie-consent.banner.component'; + +describe('CookieConsentComponent', () => { + let component: CookieConsentBannerComponent; + 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: [CookieConsentBannerComponent], + providers: [ + { provide: MessageService, useValue: mockToastService }, + { provide: CookieConsentService, useValue: mockConsentService }, + { provide: TranslateService, useValue: mockTranslateService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(CookieConsentBannerComponent); + 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'); + }); + }); +}); diff --git a/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent.banner.component.ts b/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent.banner.component.ts new file mode 100644 index 000000000..e2bb54f99 --- /dev/null +++ b/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent.banner.component.ts @@ -0,0 +1,42 @@ +import { TranslatePipe, 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 '../../../../shared/services/cookie-consent/cookie-consent.service'; + +@Component({ + selector: 'osf-cookie-consent-banner', + templateUrl: './cookie-consent.banner.component.html', + styleUrls: ['./cookie-consent.banner.component.scss'], + imports: [Toast, Button, PrimeTemplate, TranslatePipe], +}) +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, + }) + ); + }); + } + } + + acceptCookies() { + this.consentService.grantConsent(); + this.toastService.clear('cookie'); + } +} diff --git a/src/app/core/components/osf-banners/maintenance-banner/maintenance.banner.component.html b/src/app/core/components/osf-banners/maintenance-banner/maintenance.banner.component.html new file mode 100644 index 000000000..a936ebefc --- /dev/null +++ b/src/app/core/components/osf-banners/maintenance-banner/maintenance.banner.component.html @@ -0,0 +1,13 @@ +@if (maintenance() && !dismissed()) { + + +} diff --git a/src/app/core/components/osf-banners/maintenance-banner/maintenance.banner.component.scss b/src/app/core/components/osf-banners/maintenance-banner/maintenance.banner.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/core/components/osf-banners/maintenance-banner/maintenance.banner.component.spec.ts b/src/app/core/components/osf-banners/maintenance-banner/maintenance.banner.component.spec.ts new file mode 100644 index 000000000..595d0ca91 --- /dev/null +++ b/src/app/core/components/osf-banners/maintenance-banner/maintenance.banner.component.spec.ts @@ -0,0 +1,150 @@ +import { CookieService } from 'ngx-cookie-service'; + +import { MessageModule } from 'primeng/message'; + +import { of } from 'rxjs'; + +import { HttpClient } from '@angular/common/http'; +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +import { MaintenanceBannerComponent } from './maintenance.banner.component'; + +describe('Component: Maintenance Banner', () => { + let fixture: ComponentFixture; + let httpClient: { get: jest.Mock }; + let cookieService: jest.Mocked; + + beforeEach(async () => { + cookieService = { + check: jest.fn(), + set: jest.fn(), + } as any; + httpClient = { get: jest.fn() } as any; + await TestBed.configureTestingModule({ + imports: [MaintenanceBannerComponent, NoopAnimationsModule, MessageModule], + providers: [ + { provide: CookieService, useValue: cookieService }, + { provide: HttpClient, useValue: httpClient }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(MaintenanceBannerComponent); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render info banner when maintenance data is present', fakeAsync(() => { + cookieService.check.mockReturnValue(false); + const now = new Date(); + const start = new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString(); + const end = new Date(now.getTime() + 24 * 60 * 60 * 1000).toISOString(); + httpClient.get.mockReturnValueOnce( + of({ + maintenance: { level: 1, message: 'Info message', start, end }, + }) + ); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + const banner = fixture.debugElement.query(By.css('p-message')); + expect(banner).toBeTruthy(); + expect(banner.componentInstance.severity).toBe('info'); + expect(banner.nativeElement.textContent).toContain('Info message'); + })); + + it('should render warning banner when level is 2', fakeAsync(() => { + cookieService.check.mockReturnValue(false); + const now = new Date(); + const start = new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString(); + const end = new Date(now.getTime() + 24 * 60 * 60 * 1000).toISOString(); + httpClient.get.mockReturnValueOnce( + of({ + maintenance: { + level: 2, + message: 'Warning message', + start, + end, + }, + }) + ); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + const banner = fixture.debugElement.query(By.css('p-message')); + expect(banner).toBeTruthy(); + expect(banner.componentInstance.severity).toBe('warn'); + expect(banner.nativeElement.textContent).toContain('Warning message'); + })); + + it('should render danger banner when level is 3', fakeAsync(() => { + cookieService.check.mockReturnValue(false); + const now = new Date(); + const start = new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString(); + const end = new Date(now.getTime() + 24 * 60 * 60 * 1000).toISOString(); + httpClient.get.mockReturnValueOnce( + of({ + maintenance: { + level: 3, + message: 'Danger message', + start, + end, + }, + }) + ); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + const banner = fixture.debugElement.query(By.css('p-message')); + expect(banner).toBeTruthy(); + expect(banner.componentInstance.severity).toBe('error'); + expect(banner.nativeElement.textContent).toContain('Danger message'); + })); + + it('should not render banner if cookie is set', fakeAsync(() => { + cookieService.check.mockReturnValue(true); + fixture.detectChanges(); + expect(httpClient.get).not.toHaveBeenCalled(); + fixture.detectChanges(); + const banner = fixture.debugElement.query(By.css('p-message')); + expect(banner).toBeFalsy(); + })); + + it('should not render banner if outside maintenance window', fakeAsync(() => { + cookieService.check.mockReturnValue(false); + httpClient.get.mockReturnValueOnce( + of({ + maintenance: { level: 1, message: 'Old message', start: '2020-01-01T00:00:00Z', end: '2020-01-02T00:00:00Z' }, + }) + ); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + const banner = fixture.debugElement.query(By.css('p-message')); + expect(banner).toBeFalsy(); + })); + + it('should dismiss banner when close button is clicked', fakeAsync(() => { + cookieService.check.mockReturnValue(false); + const now = new Date(); + const start = new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString(); + const end = new Date(now.getTime() + 24 * 60 * 60 * 1000).toISOString(); + httpClient.get.mockReturnValueOnce( + of({ + maintenance: { level: 1, message: 'Dismiss me', start, end }, + }) + ); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + const banner = fixture.debugElement.query(By.css('p-message')); + expect(banner).toBeTruthy(); + banner.triggerEventHandler('onClose', {}); + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('p-message'))).toBeFalsy(); + expect(cookieService.set).toHaveBeenCalled(); + })); +}); diff --git a/src/app/core/components/osf-banners/maintenance-banner/maintenance.banner.component.ts b/src/app/core/components/osf-banners/maintenance-banner/maintenance.banner.component.ts new file mode 100644 index 000000000..76f7ad0b4 --- /dev/null +++ b/src/app/core/components/osf-banners/maintenance-banner/maintenance.banner.component.ts @@ -0,0 +1,100 @@ +import { CookieService } from 'ngx-cookie-service'; + +import { MessageModule } from 'primeng/message'; + +import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, Component, inject, OnInit, signal } from '@angular/core'; + +import { fadeInOutAnimation } from '@core/animations/fade.in-out.animation'; + +import { MaintenanceModel } from '../models/maintenance.model'; +import { MaintenanceService } from '../services/maintenance.service'; + +/** + * A banner component that displays a scheduled maintenance message to users. + * + * This component checks a cookie to determine whether the user has previously dismissed + * the banner. If not, it queries the maintenance status from the server and displays + * the maintenance message if one is active. + * + * The component supports animation via `fadeInOutAnimation` and is optimized with `OnPush` change detection. + * + * @example + * ```html + * + * ``` + */ +@Component({ + selector: 'osf-maintenance-banner', + imports: [CommonModule, MessageModule], + templateUrl: './maintenance.banner.component.html', + styleUrls: ['./maintenance.banner.component.scss'], + animations: [fadeInOutAnimation], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MaintenanceBannerComponent implements OnInit { + /** + * Signal to track whether the user has dismissed the banner. + */ + dismissed = signal(false); + + /** + * Signal that holds the current maintenance status fetched from the server. + */ + maintenance = signal(null); + + /** + * Service used to fetch maintenance status from the backend. + */ + private readonly maintenanceService = inject(MaintenanceService); + + /** + * Cookie service used to persist dismissal state in the browser. + */ + private readonly cookies = inject(CookieService); + + /** + * The cookie name used to store whether the user dismissed the banner. + */ + private readonly cookieName = 'osf-maintenance-dismissed'; + + /** + * Duration (in hours) to persist the dismissal cookie. + */ + private readonly cookieDurationHours = 24; + + /** + * Lifecycle hook that initializes the component: + * - Checks if dismissal cookie exists and sets `dismissed` + * - If not dismissed, triggers a fetch of current maintenance status + */ + ngOnInit(): void { + this.dismissed.set(this.cookies.check(this.cookieName)); + if (!this.dismissed()) { + this.fetchMaintenanceStatus(); + } + } + + /** + * Fetches the current maintenance status from the backend via the `MaintenanceService` + * and sets it to the `maintenance` signal. + * + * If no maintenance is active, the signal remains `null`. + */ + private fetchMaintenanceStatus(): void { + this.maintenanceService.fetchMaintenanceStatus().subscribe((maintenance: MaintenanceModel | null) => { + this.maintenance.set(maintenance); + }); + } + + /** + * Dismisses the banner: + * - Sets a cookie to remember dismissal for 24 hours + * - Updates the `dismissed` and `maintenance` signals + */ + dismiss(): void { + this.cookies.set(this.cookieName, '1', this.cookieDurationHours, '/'); + this.dismissed.set(true); + this.maintenance.set(null); + } +} diff --git a/src/app/core/components/osf-banners/osf.banner.component.html b/src/app/core/components/osf-banners/osf.banner.component.html new file mode 100644 index 000000000..b7edf3f0b --- /dev/null +++ b/src/app/core/components/osf-banners/osf.banner.component.html @@ -0,0 +1,2 @@ + + diff --git a/src/app/core/components/osf-banners/osf.banner.component.spec.ts b/src/app/core/components/osf-banners/osf.banner.component.spec.ts new file mode 100644 index 000000000..2e173b17e --- /dev/null +++ b/src/app/core/components/osf-banners/osf.banner.component.spec.ts @@ -0,0 +1,33 @@ +import { MockComponent } from 'ng-mocks'; + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +import { ScheduledBannerComponent } from './scheduled-banner/scheduled.banner.component'; +import { OSFBannerComponent } from './osf.banner.component'; + +import { OSFTestingModule } from '@testing/osf.testing.module'; +import { MockComponentWithSignal } from '@testing/providers/component-provider.mock'; + +describe('Component: OSF Banner', () => { + let fixture: ComponentFixture; + let component: OSFBannerComponent; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + OSFTestingModule, + OSFBannerComponent, + NoopAnimationsModule, + MockComponentWithSignal('osf-maintenance-banner'), + MockComponent(ScheduledBannerComponent), + ], + }).compileComponents(); + + fixture = TestBed.createComponent(OSFBannerComponent); + component = fixture.componentInstance; + }); + it('should create the component', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/core/components/osf-banners/osf.banner.component.ts b/src/app/core/components/osf-banners/osf.banner.component.ts new file mode 100644 index 000000000..d4053e5af --- /dev/null +++ b/src/app/core/components/osf-banners/osf.banner.component.ts @@ -0,0 +1,27 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; + +import { MaintenanceBannerComponent } from './maintenance-banner/maintenance.banner.component'; +import { ScheduledBannerComponent } from './scheduled-banner/scheduled.banner.component'; + +/** + * Wrapper component responsible for rendering all global or conditional banners. + * + * Currently, it includes the `MaintenanceBannerComponent`, which displays scheduled + * maintenance notices based on server configuration and cookie state. + * + * This component is structured to allow future expansion for additional banners (e.g., announcements, alerts). + * + * Change detection is set to `OnPush` to improve performance. + * + * @example + * ```html + * + * ``` + */ +@Component({ + selector: 'osf-banner-component', + imports: [MaintenanceBannerComponent, ScheduledBannerComponent], + templateUrl: './osf.banner.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class OSFBannerComponent {} diff --git a/src/app/core/components/osf-banners/scheduled-banner/scheduled.banner.component.html b/src/app/core/components/osf-banners/scheduled-banner/scheduled.banner.component.html new file mode 100644 index 000000000..cd7b9d40c --- /dev/null +++ b/src/app/core/components/osf-banners/scheduled-banner/scheduled.banner.component.html @@ -0,0 +1,16 @@ +@if (shouldShowBanner()) { +
+ + + +
+} diff --git a/src/app/core/components/osf-banners/scheduled-banner/scheduled.banner.component.spec.ts b/src/app/core/components/osf-banners/scheduled-banner/scheduled.banner.component.spec.ts new file mode 100644 index 000000000..69f334480 --- /dev/null +++ b/src/app/core/components/osf-banners/scheduled-banner/scheduled.banner.component.spec.ts @@ -0,0 +1,144 @@ +import { Store } from '@ngxs/store'; + +import { BehaviorSubject } from 'rxjs'; + +import { signal, WritableSignal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { BannerModel } from '@core/components/osf-banners/models/banner.model'; +import { IS_XSMALL } from '@osf/shared/helpers'; +import { BannersSelector, GetCurrentScheduledBanner } from '@osf/shared/stores/banners'; + +import { ScheduledBannerComponent } from './scheduled.banner.component'; + +import { provideMockStore } from '@testing/providers/store-provider.mock'; + +describe('Component: Scheduled Banner', () => { + let fixture: ComponentFixture; + let store: Store; + let component: ScheduledBannerComponent; + + const currentBannerSignal: WritableSignal = signal(null); + let isMobile$: BehaviorSubject; + + beforeEach(() => { + isMobile$ = new BehaviorSubject(false); + + TestBed.configureTestingModule({ + imports: [ScheduledBannerComponent], + providers: [ + { + provide: IS_XSMALL, + useValue: isMobile$, + }, + ], + }).overrideProvider(Store, { + useValue: provideMockStore({ + signals: [{ selector: BannersSelector.getCurrentBanner, value: currentBannerSignal }], + actions: [{ action: new GetCurrentScheduledBanner(), value: true }], + }).useValue, + }); + + fixture = TestBed.createComponent(ScheduledBannerComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + store = TestBed.inject(Store); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should create the component', () => { + expect(component).toBeInstanceOf(ScheduledBannerComponent); + }); + + it('should dispatch FetchCurrentScheduledBanner on init', () => { + const dispatchSpy = jest.spyOn(store, 'dispatch'); + component.ngOnInit(); + expect(dispatchSpy).toHaveBeenCalledWith(new GetCurrentScheduledBanner()); + }); + + it('should return false if no banner is set', () => { + currentBannerSignal.set(null); + expect(component.shouldShowBanner()).toBe(false); + }); + + it('should return true if current time is within banner window', () => { + const showBanner = component.shouldShowBanner; + expect(showBanner()).toBeFalsy(); + + const now = new Date(); + currentBannerSignal.set( + Object({ + color: '#123456', + link: '', + defaultPhoto: '', + defaultAltText: '', + mobilePhoto: '', + mobileAltText: '', + startDate: new Date(now.getTime() - 5000), + endDate: new Date(now.getTime() + 5000), + }) + ); + + fixture.detectChanges(); + expect(showBanner()).toBeTruthy(); + const parent = fixture.nativeElement.querySelector('[data-test-banner-parent]'); + const link = fixture.nativeElement.querySelector('[data-test-banner-href]'); + expect(parent.style.backgroundColor).toBe('rgb(18, 52, 86)'); // hex to rgb + expect(link.getAttribute('href')).toBe(''); + }); + + it('should return false if current time is outside the banner window', () => { + const now = new Date(); + currentBannerSignal.set( + Object({ + startDate: new Date(now.getTime() - 2000), + endDate: new Date(now.getTime() - 1000), + }) + ); + fixture.detectChanges(); + const parent = fixture.nativeElement.querySelector('[data-test-banner-parent]'); + const link = fixture.nativeElement.querySelector('[data-test-banner-href]'); + + expect(component.shouldShowBanner()).toBe(false); + expect(parent).toBeNull(); + expect(link).toBeNull(); + }); + + it('should reflect the isMobile signal', () => { + const now = new Date(); + currentBannerSignal.set( + Object({ + color: '#123456', + link: '', + defaultPhoto: 'default.jpg', + defaultAltText: 'default alt', + mobilePhoto: 'mobile.jpg', + mobileAltText: 'mobile alt', + startDate: new Date(now.getTime() - 5000), + endDate: new Date(now.getTime() + 5000), + }) + ); + fixture.detectChanges(); + const image = fixture.nativeElement.querySelector('[data-test-banner-image]'); + + expect(image.getAttribute('src')).toBe('default.jpg'); + expect(image.getAttribute('alt')).toBe('default alt'); + expect(component.isMobile()).toBe(false); + + isMobile$.next(true); + fixture.detectChanges(); + expect(image.getAttribute('src')).toBe('mobile.jpg'); + expect(image.getAttribute('alt')).toBe('mobile alt'); + expect(component.isMobile()).toBe(true); + + isMobile$.next(false); + fixture.detectChanges(); + expect(image.getAttribute('src')).toBe('default.jpg'); + expect(image.getAttribute('alt')).toBe('default alt'); + expect(component.isMobile()).toBe(false); + }); +}); diff --git a/src/app/core/components/osf-banners/scheduled-banner/scheduled.banner.component.ts b/src/app/core/components/osf-banners/scheduled-banner/scheduled.banner.component.ts new file mode 100644 index 000000000..502848d01 --- /dev/null +++ b/src/app/core/components/osf-banners/scheduled-banner/scheduled.banner.component.ts @@ -0,0 +1,69 @@ +import { Store } from '@ngxs/store'; + +import { ChangeDetectionStrategy, Component, computed, inject, OnInit } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; + +import { IS_XSMALL } from '@osf/shared/helpers'; +import { BannersSelector, GetCurrentScheduledBanner } from '@osf/shared/stores/banners'; + +/** + * Component to display a scheduled banner when one is active and visible + * based on the current time and the banner's scheduled window. + * + * This component is intended to show time-based promotional or informational banners + * fetched from the store via NGXS and displayed responsively on desktop or mobile. + * + * - Uses `ChangeDetectionStrategy.OnPush` for performance. + * - Observes the current screen size to adjust image and layout responsively. + */ +@Component({ + selector: 'osf-scheduled-banner', + templateUrl: './scheduled.banner.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ScheduledBannerComponent implements OnInit { + /** + * Injected NGXS Store instance used to dispatch actions and select state. + */ + private readonly store = inject(Store); + + /** + * Reactive signal of the currently scheduled banner fetched from the NGXS store. + */ + currentBanner = this.store.selectSignal(BannersSelector.getCurrentBanner); + + /** + * Signal representing whether the screen size is classified as 'extra small'. + * Used to conditionally render mobile-optimized images or styles. + */ + isMobile = toSignal(inject(IS_XSMALL)); + + /** + * Lifecycle hook that dispatches a store action to fetch the scheduled banner + * when the component initializes. + */ + ngOnInit(): void { + this.store.dispatch(new GetCurrentScheduledBanner()); + } + + /** + * A computed signal that determines if the current banner should be shown. + * + * @returns `true` if: + * - A banner exists + * - The current time is within the start and end time window of the banner + * + * Otherwise returns `false`. + */ + shouldShowBanner = computed(() => { + const banner = this.currentBanner(); + if (banner) { + const bannerStartTime = banner.startDate; + const bannerEndTime = banner.endDate; + const currentTime = new Date(); + + return bannerStartTime < currentTime && bannerEndTime > currentTime; + } + return false; + }); +} From 30a64ddb0cece6ac2e93c301017bfbc85faecaad Mon Sep 17 00:00:00 2001 From: Brian Pilati Date: Fri, 19 Sep 2025 10:50:20 -0500 Subject: [PATCH 3/6] chore(pr-updates): updates to the file names, removed imports and fixed the hamburger --- src/app/core/components/index.ts | 2 +- .../cookie-consent.banner.component.html | 14 ---- .../cookie-consent.banner.component.scss | 15 ---- .../cookie-consent.banner.component.spec.ts | 84 ------------------- .../cookie-consent.banner.component.ts | 42 ---------- ...html => maintenance-banner.component.html} | 0 ...scss => maintenance-banner.component.scss} | 0 ...s => maintenance-banner.component.spec.ts} | 2 +- ...ent.ts => maintenance-banner.component.ts} | 4 +- .../osf-banners/osf.banner.component.html | 2 - .../osf-banners/osf.banner.component.spec.ts | 33 -------- .../osf-banners/osf.banner.component.ts | 27 ------ ...t.html => scheduled-banner.component.html} | 0 ....ts => scheduled-banner.component.spec.ts} | 2 +- ...onent.ts => scheduled-banner.component.ts} | 2 +- .../core/components/root/root.component.ts | 2 +- src/app/shared/components/index.ts | 1 - 17 files changed, 7 insertions(+), 225 deletions(-) delete mode 100644 src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent.banner.component.html delete mode 100644 src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent.banner.component.scss delete mode 100644 src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent.banner.component.spec.ts delete mode 100644 src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent.banner.component.ts rename src/app/core/components/osf-banners/maintenance-banner/{maintenance.banner.component.html => maintenance-banner.component.html} (100%) rename src/app/core/components/osf-banners/maintenance-banner/{maintenance.banner.component.scss => maintenance-banner.component.scss} (100%) rename src/app/core/components/osf-banners/maintenance-banner/{maintenance.banner.component.spec.ts => maintenance-banner.component.spec.ts} (98%) rename src/app/core/components/osf-banners/maintenance-banner/{maintenance.banner.component.ts => maintenance-banner.component.ts} (96%) delete mode 100644 src/app/core/components/osf-banners/osf.banner.component.html delete mode 100644 src/app/core/components/osf-banners/osf.banner.component.spec.ts delete mode 100644 src/app/core/components/osf-banners/osf.banner.component.ts rename src/app/core/components/osf-banners/scheduled-banner/{scheduled.banner.component.html => scheduled-banner.component.html} (100%) rename src/app/core/components/osf-banners/scheduled-banner/{scheduled.banner.component.spec.ts => scheduled-banner.component.spec.ts} (98%) rename src/app/core/components/osf-banners/scheduled-banner/{scheduled.banner.component.ts => scheduled-banner.component.ts} (97%) diff --git a/src/app/core/components/index.ts b/src/app/core/components/index.ts index a7def29b1..db8885c36 100644 --- a/src/app/core/components/index.ts +++ b/src/app/core/components/index.ts @@ -1,7 +1,7 @@ export { BreadcrumbComponent } from './breadcrumb/breadcrumb.component'; export { FooterComponent } from './footer/footer.component'; export { HeaderComponent } from './header/header.component'; -export { MaintenanceBannerComponent } from './osf-banners/maintenance-banner/maintenance.banner.component'; +export { MaintenanceBannerComponent } from './osf-banners/maintenance-banner/maintenance-banner.component'; export { PageNotFoundComponent } from './page-not-found/page-not-found.component'; export { RootComponent } from './root/root.component'; export { SidenavComponent } from './sidenav/sidenav.component'; diff --git a/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent.banner.component.html b/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent.banner.component.html deleted file mode 100644 index 7d6f5baae..000000000 --- a/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent.banner.component.html +++ /dev/null @@ -1,14 +0,0 @@ - - -
- {{ message.detail }} -
- -
-
-
-
diff --git a/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent.banner.component.scss b/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent.banner.component.scss deleted file mode 100644 index 6f2a7c8c4..000000000 --- a/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent.banner.component.scss +++ /dev/null @@ -1,15 +0,0 @@ -: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/core/components/osf-banners/cookie-consent-banner/cookie-consent.banner.component.spec.ts b/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent.banner.component.spec.ts deleted file mode 100644 index 6300716f4..000000000 --- a/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent.banner.component.spec.ts +++ /dev/null @@ -1,84 +0,0 @@ -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 '../../../../shared/services/cookie-consent/cookie-consent.service'; - -import { CookieConsentBannerComponent } from './cookie-consent.banner.component'; - -describe('CookieConsentComponent', () => { - let component: CookieConsentBannerComponent; - 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: [CookieConsentBannerComponent], - providers: [ - { provide: MessageService, useValue: mockToastService }, - { provide: CookieConsentService, useValue: mockConsentService }, - { provide: TranslateService, useValue: mockTranslateService }, - ], - }).compileComponents(); - - fixture = TestBed.createComponent(CookieConsentBannerComponent); - 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'); - }); - }); -}); diff --git a/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent.banner.component.ts b/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent.banner.component.ts deleted file mode 100644 index e2bb54f99..000000000 --- a/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent.banner.component.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { TranslatePipe, 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 '../../../../shared/services/cookie-consent/cookie-consent.service'; - -@Component({ - selector: 'osf-cookie-consent-banner', - templateUrl: './cookie-consent.banner.component.html', - styleUrls: ['./cookie-consent.banner.component.scss'], - imports: [Toast, Button, PrimeTemplate, TranslatePipe], -}) -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, - }) - ); - }); - } - } - - acceptCookies() { - this.consentService.grantConsent(); - this.toastService.clear('cookie'); - } -} diff --git a/src/app/core/components/osf-banners/maintenance-banner/maintenance.banner.component.html b/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.html similarity index 100% rename from src/app/core/components/osf-banners/maintenance-banner/maintenance.banner.component.html rename to src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.html diff --git a/src/app/core/components/osf-banners/maintenance-banner/maintenance.banner.component.scss b/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.scss similarity index 100% rename from src/app/core/components/osf-banners/maintenance-banner/maintenance.banner.component.scss rename to src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.scss diff --git a/src/app/core/components/osf-banners/maintenance-banner/maintenance.banner.component.spec.ts b/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.spec.ts similarity index 98% rename from src/app/core/components/osf-banners/maintenance-banner/maintenance.banner.component.spec.ts rename to src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.spec.ts index 595d0ca91..1711da79c 100644 --- a/src/app/core/components/osf-banners/maintenance-banner/maintenance.banner.component.spec.ts +++ b/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.spec.ts @@ -9,7 +9,7 @@ import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testin import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { MaintenanceBannerComponent } from './maintenance.banner.component'; +import { MaintenanceBannerComponent } from './maintenance-banner.component'; describe('Component: Maintenance Banner', () => { let fixture: ComponentFixture; diff --git a/src/app/core/components/osf-banners/maintenance-banner/maintenance.banner.component.ts b/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.ts similarity index 96% rename from src/app/core/components/osf-banners/maintenance-banner/maintenance.banner.component.ts rename to src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.ts index 76f7ad0b4..2632ce03d 100644 --- a/src/app/core/components/osf-banners/maintenance-banner/maintenance.banner.component.ts +++ b/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.ts @@ -27,8 +27,8 @@ import { MaintenanceService } from '../services/maintenance.service'; @Component({ selector: 'osf-maintenance-banner', imports: [CommonModule, MessageModule], - templateUrl: './maintenance.banner.component.html', - styleUrls: ['./maintenance.banner.component.scss'], + templateUrl: './maintenance-banner.component.html', + styleUrls: ['./maintenance-banner.component.scss'], animations: [fadeInOutAnimation], changeDetection: ChangeDetectionStrategy.OnPush, }) diff --git a/src/app/core/components/osf-banners/osf.banner.component.html b/src/app/core/components/osf-banners/osf.banner.component.html deleted file mode 100644 index b7edf3f0b..000000000 --- a/src/app/core/components/osf-banners/osf.banner.component.html +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/src/app/core/components/osf-banners/osf.banner.component.spec.ts b/src/app/core/components/osf-banners/osf.banner.component.spec.ts deleted file mode 100644 index 2e173b17e..000000000 --- a/src/app/core/components/osf-banners/osf.banner.component.spec.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { MockComponent } from 'ng-mocks'; - -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { NoopAnimationsModule } from '@angular/platform-browser/animations'; - -import { ScheduledBannerComponent } from './scheduled-banner/scheduled.banner.component'; -import { OSFBannerComponent } from './osf.banner.component'; - -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { MockComponentWithSignal } from '@testing/providers/component-provider.mock'; - -describe('Component: OSF Banner', () => { - let fixture: ComponentFixture; - let component: OSFBannerComponent; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ - OSFTestingModule, - OSFBannerComponent, - NoopAnimationsModule, - MockComponentWithSignal('osf-maintenance-banner'), - MockComponent(ScheduledBannerComponent), - ], - }).compileComponents(); - - fixture = TestBed.createComponent(OSFBannerComponent); - component = fixture.componentInstance; - }); - it('should create the component', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/src/app/core/components/osf-banners/osf.banner.component.ts b/src/app/core/components/osf-banners/osf.banner.component.ts deleted file mode 100644 index d4053e5af..000000000 --- a/src/app/core/components/osf-banners/osf.banner.component.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; - -import { MaintenanceBannerComponent } from './maintenance-banner/maintenance.banner.component'; -import { ScheduledBannerComponent } from './scheduled-banner/scheduled.banner.component'; - -/** - * Wrapper component responsible for rendering all global or conditional banners. - * - * Currently, it includes the `MaintenanceBannerComponent`, which displays scheduled - * maintenance notices based on server configuration and cookie state. - * - * This component is structured to allow future expansion for additional banners (e.g., announcements, alerts). - * - * Change detection is set to `OnPush` to improve performance. - * - * @example - * ```html - * - * ``` - */ -@Component({ - selector: 'osf-banner-component', - imports: [MaintenanceBannerComponent, ScheduledBannerComponent], - templateUrl: './osf.banner.component.html', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class OSFBannerComponent {} diff --git a/src/app/core/components/osf-banners/scheduled-banner/scheduled.banner.component.html b/src/app/core/components/osf-banners/scheduled-banner/scheduled-banner.component.html similarity index 100% rename from src/app/core/components/osf-banners/scheduled-banner/scheduled.banner.component.html rename to src/app/core/components/osf-banners/scheduled-banner/scheduled-banner.component.html diff --git a/src/app/core/components/osf-banners/scheduled-banner/scheduled.banner.component.spec.ts b/src/app/core/components/osf-banners/scheduled-banner/scheduled-banner.component.spec.ts similarity index 98% rename from src/app/core/components/osf-banners/scheduled-banner/scheduled.banner.component.spec.ts rename to src/app/core/components/osf-banners/scheduled-banner/scheduled-banner.component.spec.ts index 69f334480..7379b2013 100644 --- a/src/app/core/components/osf-banners/scheduled-banner/scheduled.banner.component.spec.ts +++ b/src/app/core/components/osf-banners/scheduled-banner/scheduled-banner.component.spec.ts @@ -9,7 +9,7 @@ import { BannerModel } from '@core/components/osf-banners/models/banner.model'; import { IS_XSMALL } from '@osf/shared/helpers'; import { BannersSelector, GetCurrentScheduledBanner } from '@osf/shared/stores/banners'; -import { ScheduledBannerComponent } from './scheduled.banner.component'; +import { ScheduledBannerComponent } from './scheduled-banner.component'; import { provideMockStore } from '@testing/providers/store-provider.mock'; diff --git a/src/app/core/components/osf-banners/scheduled-banner/scheduled.banner.component.ts b/src/app/core/components/osf-banners/scheduled-banner/scheduled-banner.component.ts similarity index 97% rename from src/app/core/components/osf-banners/scheduled-banner/scheduled.banner.component.ts rename to src/app/core/components/osf-banners/scheduled-banner/scheduled-banner.component.ts index 502848d01..f2d8c4630 100644 --- a/src/app/core/components/osf-banners/scheduled-banner/scheduled.banner.component.ts +++ b/src/app/core/components/osf-banners/scheduled-banner/scheduled-banner.component.ts @@ -18,7 +18,7 @@ import { BannersSelector, GetCurrentScheduledBanner } from '@osf/shared/stores/b */ @Component({ selector: 'osf-scheduled-banner', - templateUrl: './scheduled.banner.component.html', + templateUrl: './scheduled-banner.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export class ScheduledBannerComponent implements OnInit { diff --git a/src/app/core/components/root/root.component.ts b/src/app/core/components/root/root.component.ts index 0f7d00c9b..81b867196 100644 --- a/src/app/core/components/root/root.component.ts +++ b/src/app/core/components/root/root.component.ts @@ -13,7 +13,7 @@ import { IS_MEDIUM, IS_WEB } from '@osf/shared/helpers'; import { BreadcrumbComponent } from '../breadcrumb/breadcrumb.component'; import { FooterComponent } from '../footer/footer.component'; import { HeaderComponent } from '../header/header.component'; -import { OSFBannerComponent } from '../osf-banners/osf.banner.component'; +import { OSFBannerComponent } from '../osf-banners/osf-banner.component'; import { SidenavComponent } from '../sidenav/sidenav.component'; import { TopnavComponent } from '../topnav/topnav.component'; diff --git a/src/app/shared/components/index.ts b/src/app/shared/components/index.ts index c174578ca..a84985ba6 100644 --- a/src/app/shared/components/index.ts +++ b/src/app/shared/components/index.ts @@ -1,4 +1,3 @@ -export { CookieConsentBannerComponent as CookieConsentComponent } from '../../core/components/osf-banners/cookie-consent-banner/cookie-consent.banner.component'; export { AddProjectFormComponent } from './add-project-form/add-project-form.component'; export { AffiliatedInstitutionSelectComponent } from './affiliated-institution-select/affiliated-institution-select.component'; export { AffiliatedInstitutionsViewComponent } from './affiliated-institutions-view/affiliated-institutions-view.component'; From 35d405c607515eabfdc8fc3eeb4003bae3d64934 Mon Sep 17 00:00:00 2001 From: Brian Pilati Date: Fri, 19 Sep 2025 12:03:45 -0500 Subject: [PATCH 4/6] feat(eng-8901-5): updated the cookie-consent-banner with tests --- src/app/app.component.html | 1 - src/app/app.component.ts | 3 +- .../cookie-consent-banner.component.html | 26 ++--- .../cookie-consent-banner.component.scss | 14 +-- .../cookie-consent-banner.component.spec.ts | 97 +++++++------------ .../cookie-consent-banner.component.ts | 73 ++++++++------ .../maintenance-banner.component.ts | 2 + .../osf-banners/osf-banner.component.html | 2 +- .../cookie-consent.component.html | 10 -- .../cookie-consent.service.spec.ts | 42 -------- .../cookie-consent/cookie-consent.service.ts | 18 ---- 11 files changed, 100 insertions(+), 188 deletions(-) delete mode 100644 src/app/shared/components/cookie-consent/cookie-consent.component.html delete mode 100644 src/app/shared/services/cookie-consent/cookie-consent.service.spec.ts delete mode 100644 src/app/shared/services/cookie-consent/cookie-consent.service.ts diff --git a/src/app/app.component.html b/src/app/app.component.html index 2a0b455a3..4997c5280 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -1,4 +1,3 @@ - diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 7212dca7b..a9be27183 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -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'; @@ -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, diff --git a/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent-banner.component.html b/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent-banner.component.html index 7d6f5baae..f3d5f3366 100644 --- a/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent-banner.component.html +++ b/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent-banner.component.html @@ -1,14 +1,16 @@ - - -
- {{ message.detail }} -
- +@if (this.displayBanner()) { +
+
+
+
{{ 'toast.cookie-consent.message' | translate }}
+
+ +
- - +
+} diff --git a/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent-banner.component.scss b/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent-banner.component.scss index 6f2a7c8c4..9cd9d8e54 100644 --- a/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent-banner.component.scss +++ b/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent-banner.component.scss @@ -1,15 +1,3 @@ -: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; +.p-message { width: 100%; } diff --git a/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent-banner.component.spec.ts b/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent-banner.component.spec.ts index 380aef1df..9c097775f 100644 --- a/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent-banner.component.spec.ts +++ b/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent-banner.component.spec.ts @@ -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; - let mockToastService: jest.Mocked; - let mockConsentService: jest.Mocked; - let mockTranslateService: jest.Mocked; + 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; + await TestBed.configureTestingModule({ + imports: [OSFTestingModule, CookieConsentBannerComponent, Button], - mockConsentService = { - hasConsent: jest.fn(), - grantConsent: jest.fn(), - } as unknown as jest.Mocked; + providers: [{ provide: CookieService, useValue: cookieServiceMock }], + }); - mockTranslateService = { - get: jest.fn(), - } as unknown as jest.Mocked; + 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); }); }); diff --git a/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent-banner.component.ts b/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent-banner.component.ts index 03d830430..8ae5eedb5 100644 --- a/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent-banner.component.ts +++ b/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent-banner.component.ts @@ -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(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); } } diff --git a/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.ts b/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.ts index 2632ce03d..d44d2c7e9 100644 --- a/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.ts +++ b/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.ts @@ -70,6 +70,7 @@ export class MaintenanceBannerComponent implements OnInit { */ ngOnInit(): void { this.dismissed.set(this.cookies.check(this.cookieName)); + this.dismissed.set(false); if (!this.dismissed()) { this.fetchMaintenanceStatus(); } @@ -84,6 +85,7 @@ export class MaintenanceBannerComponent implements OnInit { private fetchMaintenanceStatus(): void { this.maintenanceService.fetchMaintenanceStatus().subscribe((maintenance: MaintenanceModel | null) => { this.maintenance.set(maintenance); + this.maintenance.set({ message: 'this is the message' } as MaintenanceModel); }); } diff --git a/src/app/core/components/osf-banners/osf-banner.component.html b/src/app/core/components/osf-banners/osf-banner.component.html index 1c13a42fa..e39d89a8d 100644 --- a/src/app/core/components/osf-banners/osf-banner.component.html +++ b/src/app/core/components/osf-banners/osf-banner.component.html @@ -1,3 +1,3 @@ - + diff --git a/src/app/shared/components/cookie-consent/cookie-consent.component.html b/src/app/shared/components/cookie-consent/cookie-consent.component.html deleted file mode 100644 index 409e35808..000000000 --- a/src/app/shared/components/cookie-consent/cookie-consent.component.html +++ /dev/null @@ -1,10 +0,0 @@ - - -
- {{ message.detail }} -
- -
-
-
-
diff --git a/src/app/shared/services/cookie-consent/cookie-consent.service.spec.ts b/src/app/shared/services/cookie-consent/cookie-consent.service.spec.ts deleted file mode 100644 index 84e0aa7d2..000000000 --- a/src/app/shared/services/cookie-consent/cookie-consent.service.spec.ts +++ /dev/null @@ -1,42 +0,0 @@ -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); - }); -}); diff --git a/src/app/shared/services/cookie-consent/cookie-consent.service.ts b/src/app/shared/services/cookie-consent/cookie-consent.service.ts deleted file mode 100644 index 196bf75af..000000000 --- a/src/app/shared/services/cookie-consent/cookie-consent.service.ts +++ /dev/null @@ -1,18 +0,0 @@ -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); - } -} From e4a03938e5e453d4ccb22f7bd0497f8e797ca2b1 Mon Sep 17 00:00:00 2001 From: Brian Pilati Date: Fri, 19 Sep 2025 12:09:58 -0500 Subject: [PATCH 5/6] chore(test fixes): fixed some broken tests --- .../maintenance-banner/maintenance-banner.component.ts | 2 -- src/app/core/components/root/root.component.spec.ts | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.ts b/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.ts index d44d2c7e9..2632ce03d 100644 --- a/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.ts +++ b/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.ts @@ -70,7 +70,6 @@ export class MaintenanceBannerComponent implements OnInit { */ ngOnInit(): void { this.dismissed.set(this.cookies.check(this.cookieName)); - this.dismissed.set(false); if (!this.dismissed()) { this.fetchMaintenanceStatus(); } @@ -85,7 +84,6 @@ export class MaintenanceBannerComponent implements OnInit { private fetchMaintenanceStatus(): void { this.maintenanceService.fetchMaintenanceStatus().subscribe((maintenance: MaintenanceModel | null) => { this.maintenance.set(maintenance); - this.maintenance.set({ message: 'this is the message' } as MaintenanceModel); }); } diff --git a/src/app/core/components/root/root.component.spec.ts b/src/app/core/components/root/root.component.spec.ts index fce74c1b8..5af86e7e0 100644 --- a/src/app/core/components/root/root.component.spec.ts +++ b/src/app/core/components/root/root.component.spec.ts @@ -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'; From 103567cb56ca12267de70ebe2e7d3e0904f9cc53 Mon Sep 17 00:00:00 2001 From: Brian Pilati Date: Fri, 19 Sep 2025 13:15:39 -0500 Subject: [PATCH 6/6] feat(eng-8901-5): finished css with colors --- .../cookie-consent-banner.component.html | 4 ++-- .../cookie-consent-banner.component.scss | 24 +++++++++++++++++-- .../scheduled-banner.component.scss | 3 +++ .../scheduled-banner.component.ts | 1 + 4 files changed, 28 insertions(+), 4 deletions(-) create mode 100644 src/app/core/components/osf-banners/scheduled-banner/scheduled-banner.component.scss diff --git a/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent-banner.component.html b/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent-banner.component.html index f3d5f3366..5a5fa4692 100644 --- a/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent-banner.component.html +++ b/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent-banner.component.html @@ -1,6 +1,6 @@ @if (this.displayBanner()) { -
-
+