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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .azurite/__azurite_db_blob__.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"filename":"/Users/mick/Development/Open source/components/.azurite/__azurite_db_blob__.json","collections":[{"name":"$SERVICES_COLLECTION$","data":[],"idIndex":null,"binaryIndices":{},"constraints":null,"uniqueNames":["accountName"],"transforms":{},"objType":"$SERVICES_COLLECTION$","dirty":false,"cachedIndex":null,"cachedBinaryIndex":null,"cachedData":null,"adaptiveBinaryIndices":true,"transactional":false,"cloneObjects":false,"cloneMethod":"parse-stringify","asyncListeners":false,"disableMeta":false,"disableChangesApi":true,"disableDeltaChangesApi":true,"autoupdate":false,"serializableIndices":true,"disableFreeze":true,"ttl":null,"maxId":0,"DynamicViews":[],"events":{"insert":[],"update":[],"pre-insert":[],"pre-update":[],"close":[],"flushbuffer":[],"error":[],"delete":[null],"warning":[null]},"changes":[],"dirtyIds":[]},{"name":"$CONTAINERS_COLLECTION$","data":[],"idIndex":null,"binaryIndices":{"accountName":{"name":"accountName","dirty":false,"values":[]},"name":{"name":"name","dirty":false,"values":[]}},"constraints":null,"uniqueNames":[],"transforms":{},"objType":"$CONTAINERS_COLLECTION$","dirty":false,"cachedIndex":null,"cachedBinaryIndex":null,"cachedData":null,"adaptiveBinaryIndices":true,"transactional":false,"cloneObjects":false,"cloneMethod":"parse-stringify","asyncListeners":false,"disableMeta":false,"disableChangesApi":true,"disableDeltaChangesApi":true,"autoupdate":false,"serializableIndices":true,"disableFreeze":true,"ttl":null,"maxId":0,"DynamicViews":[],"events":{"insert":[],"update":[],"pre-insert":[],"pre-update":[],"close":[],"flushbuffer":[],"error":[],"delete":[null],"warning":[null]},"changes":[],"dirtyIds":[]},{"name":"$BLOBS_COLLECTION$","data":[],"idIndex":null,"binaryIndices":{"accountName":{"name":"accountName","dirty":false,"values":[]},"containerName":{"name":"containerName","dirty":false,"values":[]},"name":{"name":"name","dirty":false,"values":[]},"snapshot":{"name":"snapshot","dirty":false,"values":[]}},"constraints":null,"uniqueNames":[],"transforms":{},"objType":"$BLOBS_COLLECTION$","dirty":false,"cachedIndex":null,"cachedBinaryIndex":null,"cachedData":null,"adaptiveBinaryIndices":true,"transactional":false,"cloneObjects":false,"cloneMethod":"parse-stringify","asyncListeners":false,"disableMeta":false,"disableChangesApi":true,"disableDeltaChangesApi":true,"autoupdate":false,"serializableIndices":true,"disableFreeze":true,"ttl":null,"maxId":0,"DynamicViews":[],"events":{"insert":[],"update":[],"pre-insert":[],"pre-update":[],"close":[],"flushbuffer":[],"error":[],"delete":[null],"warning":[null]},"changes":[],"dirtyIds":[]},{"name":"$BLOCKS_COLLECTION$","data":[],"idIndex":null,"binaryIndices":{"accountName":{"name":"accountName","dirty":false,"values":[]},"containerName":{"name":"containerName","dirty":false,"values":[]},"blobName":{"name":"blobName","dirty":false,"values":[]},"name":{"name":"name","dirty":false,"values":[]}},"constraints":null,"uniqueNames":[],"transforms":{},"objType":"$BLOCKS_COLLECTION$","dirty":false,"cachedIndex":null,"cachedBinaryIndex":null,"cachedData":null,"adaptiveBinaryIndices":true,"transactional":false,"cloneObjects":false,"cloneMethod":"parse-stringify","asyncListeners":false,"disableMeta":false,"disableChangesApi":true,"disableDeltaChangesApi":true,"autoupdate":false,"serializableIndices":true,"disableFreeze":true,"ttl":null,"maxId":0,"DynamicViews":[],"events":{"insert":[],"update":[],"pre-insert":[],"pre-update":[],"close":[],"flushbuffer":[],"error":[],"delete":[null],"warning":[null]},"changes":[],"dirtyIds":[]}],"databaseVersion":1.5,"engineVersion":1.5,"autosave":true,"autosaveInterval":5000,"autosaveHandle":null,"throttledSaves":true,"options":{"persistenceMethod":"fs","autosave":true,"autosaveInterval":5000,"serializationMethod":"normal","destructureDelimiter":"$<\n"},"persistenceMethod":"fs","persistenceAdapter":null,"verbose":false,"events":{"init":[null],"loaded":[],"flushChanges":[],"close":[],"changes":[],"warning":[]},"ENV":"NODEJS"}
1 change: 1 addition & 0 deletions .azurite/__azurite_db_blob_extent__.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"filename":"/Users/mick/Development/Open source/components/.azurite/__azurite_db_blob_extent__.json","collections":[{"name":"$EXTENTS_COLLECTION$","data":[],"idIndex":null,"binaryIndices":{"id":{"name":"id","dirty":false,"values":[]}},"constraints":null,"uniqueNames":[],"transforms":{},"objType":"$EXTENTS_COLLECTION$","dirty":false,"cachedIndex":null,"cachedBinaryIndex":null,"cachedData":null,"adaptiveBinaryIndices":true,"transactional":false,"cloneObjects":false,"cloneMethod":"parse-stringify","asyncListeners":false,"disableMeta":false,"disableChangesApi":true,"disableDeltaChangesApi":true,"autoupdate":false,"serializableIndices":true,"disableFreeze":true,"ttl":null,"maxId":0,"DynamicViews":[],"events":{"insert":[],"update":[],"pre-insert":[],"pre-update":[],"close":[],"flushbuffer":[],"error":[],"delete":[null],"warning":[null]},"changes":[],"dirtyIds":[]}],"databaseVersion":1.5,"engineVersion":1.5,"autosave":true,"autosaveInterval":5000,"autosaveHandle":null,"throttledSaves":true,"options":{"persistenceMethod":"fs","autosave":true,"autosaveInterval":5000,"serializationMethod":"normal","destructureDelimiter":"$<\n"},"persistenceMethod":"fs","persistenceAdapter":null,"verbose":false,"events":{"init":[null],"loaded":[],"flushChanges":[],"close":[],"changes":[],"warning":[]},"ENV":"NODEJS"}
1 change: 0 additions & 1 deletion packages/cookie-banner/.npmignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
.DS_Store
*.log
src
__tests__
example
coverage
Expand Down
48 changes: 23 additions & 25 deletions packages/cookie-banner/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,42 +4,27 @@ GDPR compliant cookie banner and consent form.

Renders a cookie banner and a consent form based on configuration settings, and conditionally invokes cookie-reliant functionality based on user consent.

A WCAG and GDPR compliant example is available for reference at https://storm-ui-patterns.netlify.app/patterns/cookie-banner/.

---

## Usage
Cookie consent is based on categorising cookies and the functions that initialise them, describing them in a configuration object passed into the module at initialisition.

The cookie banner renders itself if no consent preferences are recorded in the browser.

The consent form renders into a DOMElement with a particular className configurable options (classNames.formContainer).

A page containing a cookie consent form should include a visually hidden live region (role=alert) with a particular className (classNames.formAnnouncement), default: 'privacy-banner__form-announcement'.

Optionally the banner also supports basic Google EU consent mode [https://developers.google.com/tag-platform/security/guides/consent?consentmode=basic], and can push user consent preferences to the dataLayer for Google libraries to use. All that is necessary to suport Google consent mode is to map Google consent categories to the cookie categories in the configuration.
Cookie consent management works by categorising cookies and the functions that initialise them, describing them in a configuration object passed into the module at initialisition.

For example, to map the ad_storage, ad_user_data, and ad_personalisation to an 'ads' consent category defined in the banner config, add a `euConsentTypes` object to the configuration like this:

```
euConsentTypes: {
ad_storage: 'test',
ad_user_data: 'test',
ad_personalization: 'test',
analytics_storage: 'test'
}
```
The cookie banner renders itself if no consent preferences are recorded in the browser, or if recorded consent categories do not match.


Install the package
```
npm i -S @stormid/cookie-banner
```

Create a container element for the consent form.
Create a container element for the consent form. The consent form renders into a DOMElement with a className matching the classNames.formContainer option.
```
<div class="privacy-banner__form-container"></div>
```

Create a visually hidden live region for the screen reader announcement.
A page containing a cookie consent form should also include a visually hidden live region (role=alert) with a className matching the classNames.formAnnouncement option.
```
<div class="visually-hidden privacy-banner__form-announcement" role="alert"></div>
```
Expand All @@ -51,7 +36,6 @@ import banner from '@stormid/cookie-banner';
const cookieBanner = banner({
types: {
'performance': {
suggested: true, //set as pre-checked on consent form as a suggested response
title: 'Performance preferences',
description: 'Performance cookies are used to measure the performance of our website and make improvements. Your personal data is not identified.',
labels: {
Expand Down Expand Up @@ -84,7 +68,21 @@ const cookieBanner = banner({
});
```

## Google EU consent mode
Optionally the banner also supports Google EU consent mode v2 https://developers.google.com/tag-platform/security/guides/consent, and can push user consent preferences to the dataLayer for Google libraries to use. All that is necessary to support Google consent mode is to map Google consent categories to the cookie categories in the configuration.

For example, to map the ad_storage, ad_user_data, and ad_personalisation to an 'ads' consent category defined in the banner config, add a `euConsentTypes` object to the configuration like this:
```
euConsentTypes: {
ad_storage: 'Marketing',
ad_user_data: 'Marketing',
ad_personalization: 'Marketing',
analytics_storage: 'Performance'
}
```

## Options
Full options that can be passed during initialisation:
```
{
name: '.CookiePreferences', //name of the cookie set to record user consent
Expand Down Expand Up @@ -116,7 +114,7 @@ const cookieBanner = banner({
savedMessage: 'Your settings have been saved.', //displayed after consent form update,
trapTab: false, //trap the user's keyboard tab within the banner when open
bannerTemplate(model){
return `<section role="dialog" aria-live="polite" aria-label="Your privacy" class="${model.classNames.banner}">
return `<div role="region" aria-live="polite" aria-label="Your privacy" class="${model.classNames.banner}">
<div class="privacy-content">
<div class="wrap">
<!--googleoff: all-->
Expand All @@ -128,7 +126,7 @@ const cookieBanner = banner({
<!--googleon: all-->
</div>
</div>
</section>`;
</div>`;
},
messageTemplate(model){
return `<div class="${model.settings.classNames.formMessage}" aria-role="alert">${model.settings.savedMessage}</div>`
Expand Down Expand Up @@ -176,7 +174,7 @@ const cookieBanner = banner({
```

## Utility functions
There are two utility functions provided by the library designed to be invoked following user consent.
There are two utility functions provided by the library that can be invoked following user consent.

### Render iframe
`state.utils.renderIframe`
Expand Down
6 changes: 3 additions & 3 deletions packages/cookie-banner/__tests__/banner/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,11 @@ describe(`Cookie banner > DOM > not render`, () => {

describe(`Cookie banner > DOM > accessibility`, () => {
beforeAll(init);
it('The banner should be a dialog', async () => {
expect(document.querySelector(`.${defaults.classNames.banner}`).getAttribute('role')).toEqual('dialog');
it('The banner should be a region', async () => {
expect(document.querySelector(`.${defaults.classNames.banner}`).getAttribute('role')).toEqual('region');
});

it('The banner should have be polite aria live region', async () => {
it('The banner should be a polite aria live region', async () => {
expect(document.querySelector(`.${defaults.classNames.banner}`).getAttribute('aria-live')).toEqual('polite');
});

Expand Down
18 changes: 0 additions & 18 deletions packages/cookie-banner/__tests__/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,6 @@ const init = () => {

};

describe(`Cookie banner > state > init`, () => {
beforeAll(init);

it('Should return the Store.getState method on initialisation', async () => {
const Store = cookieBanner({ types: {} });
expect(Store.getState).not.toBeUndefined();
});

it('Should return the state Object from Store.getState', async () => {
const Store = cookieBanner({ types: {} });

expect(Store.getState()).toBeDefined();
expect(Store.getState().consent).toEqual({});
expect(Store.getState().settings).toBeDefined();
});

});

describe(`Cookie banner > state > update/reducers`, () => {
beforeAll(init);

Expand Down
40 changes: 40 additions & 0 deletions packages/cookie-banner/__tests__/store.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { createStore } from '../src/lib/store';

describe(`Cookie banner > Store`, () => {

const Store = createStore();
let effect = false;
const sideEffect = state => {
effect = !effect;
};

it('createStore should return an Object with an API', async () => {
expect(Store).not.toBeNull();
expect(Store.getState).not.toBeNull();
expect(Store.update).not.toBeNull();
});

it('should have a getState function that returns a private state Object', async () => {
expect(Store.state).toBeUndefined();
expect(Store.getState()).toEqual({});
});

it('should have an update function that updates state', async () => {
const nextState = { isOpen: true };
Store.update(nextState);
expect(Store.getState()).toEqual(nextState);
});

it('should have an update function that does not update state if nextState is not passed', async () => {
const Store = createStore();
Store.update();
expect(Store.getState()).toEqual({});
});

it('should have an update function that invokes any side effect functions passed after the state change, with new null or undefined state', async () => {
Store.update(null, [sideEffect]);
expect(effect).toEqual(true);
});


});
20 changes: 15 additions & 5 deletions packages/cookie-banner/__tests__/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,10 +100,20 @@ describe('Cookie > Utils > extractFromCookie > malformed JSON cookie', () => {

});

describe('Cookie > Utils > extractFromCookie > category mismatch', () => {
it('should return default hasCookie and content properties if categroies do not match', () => {
document.cookie = `${defaults.name}=${btoa(JSON.stringify({ consent: { performance: 1, thirdParty: 0 } }))}`;
const [hasCookie, consent ] = extractFromCookie({ ...defaults, types: { performance: {}, thirdParty: {}, ads: {} } });

expect(hasCookie).toEqual(false);
expect(consent).toEqual({ });
});
});

describe('Cookie > Utils > extractFromCookie > well-formed JSON cookie', () => {
it('should return hasCookie and content properties from well-formed JSON cookie', () => {
document.cookie = `${defaults.name}=${btoa(JSON.stringify({ consent: { performance: 1, thirdParty: 0 } }))}`;
const [hasCookie, consent ] = extractFromCookie(defaults);
const [hasCookie, consent ] = extractFromCookie({ ...defaults, types: { performance: {}, thirdParty: {} } });

expect(hasCookie).toEqual(true);
expect(consent).toEqual({ performance: 1, thirdParty: 0 });
Expand Down Expand Up @@ -135,14 +145,14 @@ describe(`Cookie banner > Utils > broadcast`, () => {
bannerOpen: true,
settings: defaults
};
Store.update(_ => _, state);
Store.update(state);
const listener = jest.fn();
document.addEventListener(EVENTS.OPEN, listener);
document.addEventListener(EVENTS.OPEN, e => {
document.addEventListener(EVENTS.SHOW, listener);
document.addEventListener(EVENTS.SHOW, e => {
expect(e.detail).toEqual({ getState: Store.getState });
});

broadcast(EVENTS.OPEN, Store)(state);
broadcast(EVENTS.SHOW, Store)(state);
expect(listener).toHaveBeenCalled();
});

Expand Down
Loading