Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor cookiebar JS #100

Merged
merged 14 commits into from
Sep 24, 2023
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
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
sergei-maertens marked this conversation as resolved.
Show resolved Hide resolved
# 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