Skip to content

Commit

Permalink
Merge pull request #100 from jazzband/feature/99-cookiebar-js
Browse files Browse the repository at this point in the history
Refactor cookiebar JS
  • Loading branch information
sergei-maertens committed Sep 24, 2023
2 parents 2578fc0 + c89fffe commit b7f5a84
Show file tree
Hide file tree
Showing 28 changed files with 1,098 additions and 123 deletions.
20 changes: 20 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
root = true

[*]
indent_style = space
indent_size = 4
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

[*.{scss,sass}]
indent_size = 2

[*.{yml,yaml}]
indent_size = 2

[*.js]
indent_size = 2

[Makefile]
indent_style = tab
23 changes: 23 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,26 @@ jobs:
uses: codecov/codecov-action@v3
with:
directory: reports/

e2e_tests:
runs-on: ubuntu-latest
name: Run the end-to-end tests

steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: '3.10'

- name: Install dependencies
run: |
pip install tox tox-gh-actions pytest-playwright
playwright install --with-deps
- name: Run tests
run: tox -e e2e

- name: Publish coverage report
uses: codecov/codecov-action@v3
with:
directory: reports/
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,6 @@ dist/
.tox/
.pytest_cache
.coverage
htmlcov/
reports/
testapp/*.db
5 changes: 4 additions & 1 deletion cookie_consent/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ def all_cookie_groups():

qs = CookieGroup.objects.filter(is_required=False)
qs = qs.prefetch_related("cookie_set")
items = dict([(g.varname, g) for g in qs])
# items = qs.in_bulk(field_name="varname")
# FIXME -> doesn't work because varname is not a unique fieldl, we need to
# make this unique
items = {group.varname: group for group in qs}
cache.set(CACHE_KEY, items, CACHE_TIMEOUT)
return items

Expand Down
2 changes: 2 additions & 0 deletions cookie_consent/compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,5 @@
from django.contrib.auth.views import (
SuccessURLAllowedHostsMixin as RedirectURLMixin,
)

__all__ = ["url_has_allowed_host_and_scheme", "RedirectURLMixin"]
22 changes: 21 additions & 1 deletion cookie_consent/models.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# -*- coding: utf-8 -*-
import re
from typing import TypedDict

from django.core.validators import RegexValidator
from django.db import models
Expand All @@ -18,6 +19,16 @@
)


class CookieGroupDict(TypedDict):
varname: str
name: str
description: str
is_required: bool
# TODO: should we output this? page cache busting would be
# required if we do this. Alternatively, set up a JSONView to output these?
# version: str


class CookieGroup(models.Model):
varname = models.CharField(
_("Variable name"), max_length=32, validators=[validate_cookie_name]
Expand Down Expand Up @@ -45,7 +56,7 @@ class Meta:
def __str__(self):
return self.name

def get_version(self):
def get_version(self) -> str:
try:
return str(self.cookie_set.all()[0].get_version())
except IndexError:
Expand All @@ -59,6 +70,15 @@ def save(self, *args, **kwargs):
super(CookieGroup, self).save(*args, **kwargs)
delete_cache()

def for_json(self) -> CookieGroupDict:
return {
"varname": self.varname,
"name": self.name,
"description": self.description,
"is_required": self.is_required,
# "version": self.get_version(),
}


class Cookie(models.Model):
cookiegroup = models.ForeignKey(
Expand Down
3 changes: 2 additions & 1 deletion cookie_consent/static/cookie_consent/cookiebar.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ function evalXCookieConsent(script) {
script.remove();
}

function showCookieBar (options) {
function lecacyShowCookieBar (options) {
const defaults = {
content: '',
cookie_groups: [],
Expand Down Expand Up @@ -64,3 +64,4 @@ function showCookieBar (options) {
});
}

window.legacyShowCookieBar = window.showCookieBar = lecacyShowCookieBar;
203 changes: 203 additions & 0 deletions cookie_consent/static/cookie_consent/cookiebar.module.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
/**
* Cookiebar functionality, as a Javascript module.
*
* About modules: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules
*
* The code is organized here in a way to make the templates work with Django's page
* cache. This means that anything user-specific (so different django session and even
* cookie consent cookies) cannot be baked into the templates, as that breaks caches.
*
* The cookie bar operates on the following principles:
*
* - The developer using the library includes the desired template in their django
* templates, using the HTML <template> element. This contains the content for the
* cookie bar.
* - The developer is responsible for loading some Javascript that loads this script.
* - The main export of this script needs to be called (showCookieBar), with the
* appropriate options.
* - The options include the backend URLs where the retrieve data, which selectors/DOM
* nodes to use for various functionality and the hooks to tap into the accept/decline
* life-cycle.
* - When a user accepts or declines (all) cookies, the call to the backend is made via
* a fetch request, bypassing any page caches and preventing full-page reloads.
*/
const DEFAULTS = {
statusUrl: undefined,
// TODO: also accept element rather than selector?
templateSelector: '#cookie-consent__cookie-bar',
cookieGroupsSelector: '#cookie-consent__cookie-groups',
acceptSelector: '.cookie-consent__accept',
declineSelector: '.cookie-consent__decline',
/**
* Either a string (selector), DOMNode or null.
*
* If null, the bar is appended to the body. If provided, the node is used or looked
* up.
*/
insertBefore: null,
onShow: null, // callback when the cookie bar is being shown -> add class to body...
onAccept: null, // callback when cookies are accepted
onDecline: null, // callback when cookies are declined
csrfHeaderName: 'X-CSRFToken', // Django's default, can be overridden with settings.CSRF_HEADER_NAME
};

const DEFAULT_HEADERS = {'X-Cookie-Consent-Fetch': '1'};

let CONFIGURATION = DEFAULTS;
/**
* Cookie accept status, including the accept/decline URLs, csrftoken... See
* backend view CookieStatusView.
*/
let COOKIE_STATUS = null;

export const loadCookieGroups = (selector) => {
const node = document.querySelector(selector);
if (!node) {
throw new Error(`No cookie groups (script) tag found, using selector: '${selector}'`);
}
return JSON.parse(node.innerText);
};

const doInsertBefore = (beforeNode, newNode) => {
const parent = beforeNode.parentNode;
parent.insertBefore(newNode, beforeNode);
}

/**
* Register the accept/decline event handlers.
*
* Note that we can't just set the decline or accept cookie purely client-side, as the
* cookie possibly has the httpOnly flag set.
*
* @param {HTMLEelement} cookieBarNode The DOM node containing the cookiebar markup.
* @param {Array} cookieGroups The array of all configured cookie groups.
* @return {Void}
*/
const registerEvents = (cookieBarNode, cookieGroups) => {
const {acceptSelector, onAccept, declineSelector, onDecline} = CONFIGURATION;
const {
acceptedCookieGroups: accepted,
declinedCookieGroups: declined,
notAcceptedOrDeclinedCookieGroups: undecided,
} = COOKIE_STATUS;

cookieBarNode
.querySelector(acceptSelector)
.addEventListener('click', event => {
event.preventDefault();
const acceptedGroups = filterCookieGroups(cookieGroups, accepted.concat(undecided));
onAccept?.(acceptedGroups, event);
acceptCookiesBackend();
cookieBarNode.parentNode.removeChild(cookieBarNode);
});

cookieBarNode
.querySelector(declineSelector)
.addEventListener('click', event => {
event.preventDefault();
const declinedGroups = filterCookieGroups(cookieGroups, declined.concat(undecided));
onDecline?.(declinedGroups, event);
declineCookiesBackend();
cookieBarNode.parentNode.removeChild(cookieBarNode);
});
};

const loadCookieStatus = async () => {
const {statusUrl} = CONFIGURATION;
if (!statusUrl) console.error('Missing status URL option, did you forget to pass the statusUrl option?');
const response = await window.fetch(
CONFIGURATION.statusUrl,
{
method: 'GET',
credentials: 'same-origin',
headers: DEFAULT_HEADERS
}
);
// assign to module level variable, once the page is loaded these details should
// not change.
COOKIE_STATUS = await response.json();
};

const saveCookiesStatusBackend = async (urlProperty) => {
const status = COOKIE_STATUS || {};
const url = status[urlProperty];
if (!url) {
console.error(`Missing url for ${urlProperty} - was the cookie status not loaded properly?`);
return;
}
await window.fetch(url, {
method: 'POST',
credentials: 'same-origin',
headers: {
...DEFAULT_HEADERS,
[CONFIGURATION.csrfHeaderName]: status.csrftoken
}
});
}

/**
* Make the call to the backend to accept the cookies.
*/
const acceptCookiesBackend = async () => await saveCookiesStatusBackend('acceptUrl');
/**
* Make the call to the backend to decline the cookies.
*/
const declineCookiesBackend = async () => await saveCookiesStatusBackend('declineUrl');

/**
* Filter the cookie groups down to a subset of specified varnames.
*/
const filterCookieGroups = (cookieGroups, varNames) => {
return cookieGroups.filter(group => varNames.includes(group.varname));
};

export const showCookieBar = async (options={}) => {
// merge defaults and provided options
CONFIGURATION = {...DEFAULTS, ...options};
const {
cookieGroupsSelector,
templateSelector,
insertBefore,
onShow,
onAccept,
onDecline,
} = CONFIGURATION;
const cookieGroups = loadCookieGroups(cookieGroupsSelector);

// no cookie groups -> abort
if (!cookieGroups.length) return;

const templateNode = document.querySelector(templateSelector);

// insert before a given node, if specified, or append to the body as default behaviour
const doInsert = insertBefore === null
? (cookieBarNode) => document.querySelector('body').appendChild(cookieBarNode)
: typeof insertBefore === 'string'
? (cookieBarNode) => doInsertBefore(document.querySelector(insertBefore), cookieBarNode)
: (cookieBarNode) => doInsertBefore(insertBefore, cookieBarNode)
;
await loadCookieStatus();

// calculate the cookie groups to invoke the callbacks. We deliberately fire those
// without awaiting so that our cookie bar is shown/hidden as soon as possible.
const {
acceptedCookieGroups: accepted,
declinedCookieGroups: declined,
notAcceptedOrDeclinedCookieGroups
} = COOKIE_STATUS;

const acceptedGroups = filterCookieGroups(cookieGroups, accepted);
if (acceptedGroups.length) onAccept?.(acceptedGroups);
const declinedGroups = filterCookieGroups(cookieGroups, declined);
if (declinedGroups.length) onDecline?.(declinedGroups);

// there are no (more) cookie groups to accept, don't show the bar
if (!notAcceptedOrDeclinedCookieGroups.length) return;

// grab the contents from the template node and add them to the DOM, optionally
// calling the onShow callback
const cookieBarNode = templateNode.content.firstElementChild.cloneNode(true);
registerEvents(cookieBarNode, cookieGroups);
onShow?.();
doInsert(cookieBarNode);
};
Loading

0 comments on commit b7f5a84

Please sign in to comment.