diff --git a/app/scripts/components/common/banner/index.tsx b/app/scripts/components/common/banner/index.tsx
index d3b1ac55e..6272bb57f 100644
--- a/app/scripts/components/common/banner/index.tsx
+++ b/app/scripts/components/common/banner/index.tsx
@@ -1,6 +1,9 @@
-import React, { useState } from "react";
-import { Icon } from "@trussworks/react-uswds";
-import { USWDSBanner, USWDSBannerContent } from "$components/common/uswds/banner";
+import React, { useState } from 'react';
+import { Icon } from '@trussworks/react-uswds';
+import {
+ USWDSBanner,
+ USWDSBannerContent
+} from '$components/common/uswds/banner';
const BANNER_KEY = 'dismissedBannerUrl';
@@ -12,53 +15,64 @@ function hasExpired(expiryDatetime) {
enum BannerType {
info = 'info',
- warning ='warning'
+ warning = 'warning'
}
const infoTypeFlag = BannerType.info;
interface BannerProps {
- appTitle: string,
- expires: Date,
- url: string,
- text: string,
- type?: BannerType
+ appTitle: string;
+ expires: Date;
+ url: string;
+ text: string;
+ type?: BannerType;
}
-export default function Banner({appTitle, expires, url, text, type = infoTypeFlag }: BannerProps) {
+export default function Banner({
+ appTitle,
+ expires,
+ url,
+ text,
+ type = infoTypeFlag
+}: BannerProps) {
const showBanner = localStorage.getItem(BANNER_KEY) !== url;
- const [isOpen, setIsOpen] = useState(showBanner && !(hasExpired(expires)));
+ const [isOpen, setIsOpen] = useState(showBanner && !hasExpired(expires));
- function onClose () {
- localStorage.setItem(
- BANNER_KEY,
- url
- );
+ function onClose() {
+ localStorage.setItem(BANNER_KEY, url);
setIsOpen(false);
}
return (
);
}
diff --git a/app/scripts/components/common/cookie-consent/cookieConsent.spec.js b/app/scripts/components/common/cookie-consent/cookieConsent.spec.js
index ae1a92a07..7c12ad9f5 100644
--- a/app/scripts/components/common/cookie-consent/cookieConsent.spec.js
+++ b/app/scripts/components/common/cookie-consent/cookieConsent.spec.js
@@ -2,21 +2,49 @@ import React from 'react';
import '@testing-library/jest-dom';
import { render, screen, fireEvent } from '@testing-library/react';
-import { COOKIE_CONSENT_KEY } from './utils';
-import { CookieConsent } from './index';
+import { MemoryRouter } from 'react-router-dom'; // For testing
+import { createMemoryHistory } from 'history';
+
+import * as utils from './utils';
+import CookieConsent from './index';
+const lodash = require('lodash');
describe('Cookie consent form should render with correct content.', () => {
+ const setDisplayCookieConsent = jest.fn();
+ const setGoogleTagManager = jest.fn();
const cookieData = {
title: 'Cookie Consent',
- copy: 'We use cookies to enhance your browsing experience and to help us understand how our website is used. These cookies allow us to collect data on site usage and improve our services based on your interactions. To learn more about it, see our Privacy Policy
We use cookies to enhance your browsing experience and to help us understand how our website is used. These cookies allow us to collect data on site usage and improve our services based on your interactions. To learn more about it, see our [Privacy Policy](https://www.nasa.gov/privacy/#cookies)'
+ copy: 'We use cookies to enhance your browsing experience and to help us understand how our website is used. These cookies allow us to collect data on site usage and improve our services based on your interactions. To learn more about it, see our Privacy Policy
We use cookies to enhance your browsing experience and to help us understand how our website is used. These cookies allow us to collect data on site usage and improve our services based on your interactions. To learn more about it, see our [Privacy Policy](https://www.nasa.gov/privacy/#cookies)',
+ setDisplayCookieConsent,
+ setGoogleTagManager
};
+
const onFormInteraction = jest.fn();
- beforeEach(() => {
- render(
-
- );
+
+ const history = createMemoryHistory({ initialEntries: ['/home'] });
+
+ jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+ useLocation: () => ({
+ pathname: 'localhost:3000/example/path'
+ })
+ }));
+
+ lodash.debounce = jest.fn((fn) => fn);
+
+ afterEach(() => {
+ jest.clearAllMocks();
+
+ // Clear cookies after each test
+ document.cookie = `${utils.COOKIE_CONSENT_KEY}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
});
+
it('Renders correct content', () => {
+ render(
+
+
+
+ );
expect(
screen.getByRole('link', { name: 'Privacy Policy' })
).toHaveAttribute('href', 'https://www.nasa.gov/privacy/#cookies');
@@ -34,30 +62,32 @@ describe('Cookie consent form should render with correct content.', () => {
).toBeInTheDocument();
});
- it('Check correct cookie initialization', () => {
- const resultCookie = document.cookie;
-
- expect(resultCookie).toBe(
- `${COOKIE_CONSENT_KEY}={"responded":false,"answer":false}`
- );
- });
-
it('Check correct cookie content on Decline click', () => {
+ render(
+
+
+
+ );
const button = screen.getByRole('button', { name: 'Decline Cookies' });
fireEvent.click(button);
const resultCookie = document.cookie;
expect(resultCookie).toBe(
- `${COOKIE_CONSENT_KEY}={"responded":true,"answer":false}`
+ `${utils.COOKIE_CONSENT_KEY}={"responded":true,"answer":false}`
);
});
it('Check correct cookie content on Accept click', () => {
- const button = screen.getByRole('button', { name: 'Accept Cookies' });
- fireEvent.click(button);
+ render(
+
+
+
+ );
+ const acceptButton = screen.getByRole('button', { name: 'Accept Cookies' });
+ fireEvent.click(acceptButton);
const resultCookie = document.cookie;
expect(resultCookie).toBe(
- `${COOKIE_CONSENT_KEY}={"responded":true,"answer":true}`
+ `${utils.COOKIE_CONSENT_KEY}={"responded":true,"answer":true}`
);
});
});
diff --git a/app/scripts/components/common/cookie-consent/index.tsx b/app/scripts/components/common/cookie-consent/index.tsx
index 3190584dd..1b0606491 100644
--- a/app/scripts/components/common/cookie-consent/index.tsx
+++ b/app/scripts/components/common/cookie-consent/index.tsx
@@ -1,6 +1,8 @@
import React, { useState, useEffect } from 'react';
+import { debounce } from 'lodash';
import { Icon } from '@trussworks/react-uswds';
-import { COOKIE_CONSENT_KEY } from './utils';
+
+import { setCookie, getCookie } from './utils';
import {
USWDSAlert,
USWDSButton,
@@ -12,97 +14,142 @@ import './index.scss';
interface CookieConsentProps {
title?: string | undefined;
copy?: string | undefined;
- onFormInteraction: () => void;
+ pathname: string;
+ setDisplayCookieConsentForm: (boolean) => void;
+ setGoogleTagManager: () => void;
}
-function addAttribute (copy) {
+function addAttribute(copy) {
return copy.replaceAll(' {
- const [cookieConsentResponded, SetCookieConsentResponded] =
+ const [cookieConsentResponded, setCookieConsentResponded] =
useState(false);
- const [cookieConsentAnswer, SetCookieConsentAnswer] =
+ // Debounce the setDisplayCookieConsentForm function
+ const debouncedSetCookieConsentResponded = debounce(
+ setCookieConsentResponded,
+ 500
+ );
+
+ const [cookieConsentAnswer, setCookieConsentAnswer] =
useState(false);
const [closeConsent, setCloseConsent] = useState(false);
- //Setting expiration date for cookie to expire and re-ask user for consent.
- const setCookieExpiration = () => {
- const today = new Date();
- today.setMonth(today.getMonth() + 3);
- return today.toUTCString();
- };
- const setCookie = (cookieValue, closeConsent) => {
- document.cookie = `${COOKIE_CONSENT_KEY}=${JSON.stringify(
- cookieValue
- )}; path=/; expires=${closeConsent ? '0' : setCookieExpiration()}`;
- };
+ useEffect(() => {
+ const cookieContents = getCookie();
+ if (cookieContents) {
+ if (!cookieContents.responded) {
+ setCloseConsent(false);
+ return;
+ }
+ cookieContents.answer && setGoogleTagManager();
+ setCookieConsentResponded(cookieContents.responded);
+ setCookieConsentAnswer(cookieContents.answer);
+ setDisplayCookieConsentForm(false);
+ } else {
+ setCloseConsent(false);
+ }
+ // Only run on the first render
+ }, [setGoogleTagManager, setDisplayCookieConsentForm]);
+
+ useEffect(() => {
+ if (!cookieConsentResponded) setCloseConsent(false);
+ // To render the component when user hasn't answered yet
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [pathname]);
useEffect(() => {
+ // When not responded, do nothing.
+ if (!cookieConsentResponded) return;
+ // When answer is accept cookie,
+ // 1. set up google manager
+ cookieConsentAnswer && setGoogleTagManager();
+ // 2. update the cookie value
const cookieValue = {
responded: cookieConsentResponded,
answer: cookieConsentAnswer
};
setCookie(cookieValue, closeConsent);
- onFormInteraction();
- // Ignoring setcookie for now sine it will make infinite rendering
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [cookieConsentResponded, cookieConsentAnswer, closeConsent, onFormInteraction]);
+ // 3. Tell the layout that we don't have to render this consent form
+ // from the next render of layout.
+ setTimeout(() => {
+ setDisplayCookieConsentForm(false);
+ }, 500);
+ }, [
+ cookieConsentResponded,
+ cookieConsentAnswer,
+ closeConsent,
+ setDisplayCookieConsentForm,
+ setGoogleTagManager
+ ]);
return (
-
-
- {
- setCloseConsent(true);
- }}
- unstyled
+
+ {!cookieConsentResponded && (
+
-
-
-
- {copy && (
-
- )}
-
- {
- SetCookieConsentResponded(true);
- SetCookieConsentAnswer(false);
- }}
- outline={true}
- type='button'
- >
- Decline Cookies
-
- {
- SetCookieConsentResponded(true);
- SetCookieConsentAnswer(true);
- }}
- type='button'
+
- Accept Cookies
-
-
-
+
{
+ setCloseConsent(true);
+ }}
+ unstyled
+ >
+
+
+
+ {copy && (
+
+ )}
+
+ {
+ debouncedSetCookieConsentResponded(true);
+ setCookieConsentAnswer(false);
+ setCloseConsent(true);
+ }}
+ outline={true}
+ type='button'
+ >
+ Decline Cookies
+
+ {
+ debouncedSetCookieConsentResponded(true);
+ setCookieConsentAnswer(true);
+ setCloseConsent(true);
+ }}
+ type='button'
+ >
+ Accept Cookies
+
+
+
+
+ )}
);
};
+
+export default CookieConsent;
diff --git a/app/scripts/components/common/cookie-consent/utils.test.ts b/app/scripts/components/common/cookie-consent/utils.test.ts
new file mode 100644
index 000000000..6b2f0bd2d
--- /dev/null
+++ b/app/scripts/components/common/cookie-consent/utils.test.ts
@@ -0,0 +1,21 @@
+import { readCookie, COOKIE_CONSENT_KEY } from './utils';
+
+describe('onCookie', () => {
+ let cookieValue;
+ beforeEach(() => {
+ cookieValue = { responded: false, answer: false };
+ // Mutating docmument cookie property for test
+ // eslint-disable-next-line fp/no-mutating-methods
+ Object.defineProperty(window.document, 'cookie', {
+ writable: true,
+ value: `CookieConsent={"responded":true,"answer":false}; _somethingelse=GS1.1.17303800; ${COOKIE_CONSENT_KEY}=${JSON.stringify(
+ cookieValue
+ )}`
+ });
+ });
+
+ it('should parse cookie value correctly', () => {
+ const cookieJsonVal = readCookie(COOKIE_CONSENT_KEY);
+ expect(JSON.parse(cookieJsonVal)).toMatchObject(cookieValue);
+ });
+});
diff --git a/app/scripts/components/common/cookie-consent/utils.ts b/app/scripts/components/common/cookie-consent/utils.ts
index 8cdcd2b9d..74faf765e 100644
--- a/app/scripts/components/common/cookie-consent/utils.ts
+++ b/app/scripts/components/common/cookie-consent/utils.ts
@@ -1,2 +1,32 @@
-export const NO_COOKIE = 'NO COOKIE';
export const COOKIE_CONSENT_KEY = `veda--CookieConsent`;
+export const SESSION_KEY = `veda--NewSession`;
+
+export const readCookie = (name: string): string => {
+ // Get name followed by anything except a semicolon
+ const cookiestring = RegExp(name + '=[^;]+').exec(document.cookie);
+ // Return everything after the equal sign, or an empty string if the cookie name not found
+ return decodeURIComponent(
+ cookiestring ? cookiestring.toString().replace(/^[^=]+./, '') : ''
+ );
+};
+
+export const getCookie = () => {
+ const cookie = readCookie(COOKIE_CONSENT_KEY);
+ if (cookie) {
+ const cookieContents = JSON.parse(cookie);
+ return cookieContents;
+ }
+};
+
+//Setting expiration date for cookie to expire and re-ask user for consent.
+export const setCookieExpiration = () => {
+ const today = new Date();
+ today.setMonth(today.getMonth() + 3);
+ return today.toUTCString();
+};
+
+export const setCookie = (cookieValue, closeConsent) => {
+ document.cookie = `${COOKIE_CONSENT_KEY}=${JSON.stringify(
+ cookieValue
+ )}; path=/; expires=${closeConsent ? '0' : setCookieExpiration()}`;
+};
diff --git a/app/scripts/components/common/layout-root/index.tsx b/app/scripts/components/common/layout-root/index.tsx
index 241d8fd6a..6fe9add22 100644
--- a/app/scripts/components/common/layout-root/index.tsx
+++ b/app/scripts/components/common/layout-root/index.tsx
@@ -1,15 +1,20 @@
-import React, { ReactNode, useContext, useCallback, useEffect } from 'react';
-import { Link } from 'react-router-dom';
+import React, {
+ ReactNode,
+ useContext,
+ useCallback,
+ useEffect,
+ useState
+} from 'react';
+import { Link, useLocation } from 'react-router-dom';
import { useDeepCompareEffect } from 'use-deep-compare';
import styled from 'styled-components';
import { Outlet } from 'react-router';
import { reveal } from '@devseed-ui/animation';
-import { getCookieConsentFromVedaConfig } from 'veda';
+import { getBannerFromVedaConfig, getCookieConsentFromVedaConfig } from 'veda';
import MetaTags from '../meta-tags';
import PageFooter from '../page-footer';
-import Banner from '../banner';
-import { CookieConsent } from '../cookie-consent';
-import { COOKIE_CONSENT_KEY, NO_COOKIE } from '../cookie-consent/utils';
+const Banner = React.lazy(() => import('../banner'));
+const CookieConsent = React.lazy(() => import('../cookie-consent'));
import { LayoutRootContext } from './context';
@@ -44,43 +49,18 @@ const PageBody = styled.div`
function LayoutRoot(props: { children?: ReactNode }) {
const cookieConsentContent = getCookieConsentFromVedaConfig();
- const readCookie = (name) => {
- const nameEQ = name + '=';
- const attribute = document.cookie.split(';');
- for (let i = 0; i < attribute.length; i++) {
- let c = attribute[i];
- while (c.charAt(0) == ' ') c = c.substring(1, c.length);
- if (c.indexOf(nameEQ) == 0) return c.substring(nameEQ.length, c.length);
- }
- return null;
- };
-
- const getCookie = () => {
- const cookie = readCookie(COOKIE_CONSENT_KEY);
- if (cookie) {
- const cookieContents = JSON.parse(cookie);
- if (cookieContents.answer) setGoogleTagManager();
- return cookieContents;
- }
- return NO_COOKIE;
- };
-
- const showForm = () => {
- const cookieContents = getCookie();
- if (cookieContents === NO_COOKIE) {
- return true;
- } else {
- return !cookieContents.responded;
- }
- };
+ const bannerContent = getBannerFromVedaConfig();
const { children } = props;
-
+ const [displayCookieConsentForm, setDisplayCookieConsentForm] =
+ useState(true);
+ const { pathname } = useLocation();
useEffect(() => {
+ // When there is no cookie consent form set up
!cookieConsentContent && setGoogleTagManager();
}, []);
- const { title, thumbnail, description, banner, hideFooter } =
+ const { title, thumbnail, description, hideFooter } =
useContext(LayoutRootContext);
const truncatedTitle =
@@ -94,19 +74,27 @@ function LayoutRoot(props: { children?: ReactNode }) {
description={description || appDescription}
thumbnail={thumbnail}
/>
- {banner && }
+ {bannerContent && (
+
+ )}
}
+ logo={
+
+ }
/>
{children}
- {cookieConsentContent && showForm() && (
+ {cookieConsentContent && displayCookieConsentForm && (
)}
diff --git a/docs/content/CONFIGURATION.md b/docs/content/CONFIGURATION.md
index 1c2170dc8..159b7b8d0 100644
--- a/docs/content/CONFIGURATION.md
+++ b/docs/content/CONFIGURATION.md
@@ -67,6 +67,19 @@ type?: BannerType
| text | string | The text content to display in the banner. This can be an HTML string. | 'Read the new data insight on using EMIT and AVIRIS-3 for monitoring large methane emission events.' |
| type | enum('info', 'warning') |The type of information delivered by the banner, which determines its background color. | 'info'|
+### Cookie Consent Form
+
+`cookieConsentForm` object allows you to display a site-wide Cookie Consent form that sits atop your application. To create a Cookie Consent form, you need to provide two attributes as outlined below.
+
+```
+title: string,
+copy: string,
+```
+
+| Option | Type | Description| Example|
+|---|---|---|---|
+| title | string | The text content to display in the title of the cookie consent form. This can be an HTML string. | 'Cookie Consent'|
+| copy | string | The content of the Cookie Consent form, typically is a string that follows MDX documentation format. Allowing flexibility to link to different data management policy. | 'To learn more about it, see our [Privacy Policy ]\(https://www.nasa.gov/privacy/#cookies)\' |
## Meta files
diff --git a/parcel-resolver-veda/index.d.ts b/parcel-resolver-veda/index.d.ts
index 5b558b4ca..6be6a982e 100644
--- a/parcel-resolver-veda/index.d.ts
+++ b/parcel-resolver-veda/index.d.ts
@@ -264,6 +264,7 @@ declare module 'veda' {
const infoTypeFlag = BannerType.info;
interface BannerData {
expires: Date;
+ title: string;
url: string;
text: string;
type?: BannerType;
diff --git a/parcel-resolver-veda/index.js b/parcel-resolver-veda/index.js
index 3eca15e2a..6aaed15ca 100644
--- a/parcel-resolver-veda/index.js
+++ b/parcel-resolver-veda/index.js
@@ -86,9 +86,27 @@ function generateMdxDataObject(data) {
function getCookieConsentForm(result) {
if (!result.cookieConsentForm) return undefined;
else {
- const parsedCopy = md.render(result.cookieConsentForm.copy)
+ const parsedCopy = md.render(result.cookieConsentForm.copy);
const trimmedCopy = parsedCopy.replace(/(\r\n|\n|\r)/gm, '');
- return JSON.stringify({ title: result.cookieConsentForm.title, copy: trimmedCopy});
+ return JSON.stringify({
+ title: result.cookieConsentForm.title,
+ copy: trimmedCopy
+ });
+ }
+}
+
+function getBannerContent(result) {
+ if (!result.banner) return undefined;
+ else {
+ const parsedCopy = md.render(result.banner.text);
+ const trimmedCopy = parsedCopy.replace(/(\r\n|\n|\r)/gm, '');
+ return JSON.stringify({
+ title: result.banner.title,
+ text: trimmedCopy,
+ url: result.banner.url,
+ expires: result.banner.expires,
+ type: result.banner.type
+ });
}
}
@@ -207,7 +225,7 @@ module.exports = new Resolver({
)},
strings: ${JSON.stringify(withDefaultStrings(result.strings))},
booleans: ${JSON.stringify(withDefaultStrings(result.booleans))},
- banner: ${JSON.stringify(result.banner)},
+ banner: ${getBannerContent(result)},
navItems: ${JSON.stringify(result.navItems)},
cookieConsentForm: ${getCookieConsentForm(result)}
};