diff --git a/.eslintrc.json b/.eslintrc.json index 4cf89a19..f00dbb5e 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -9,6 +9,9 @@ "ecmaVersion": 2018, "sourceType": "module" }, + "globals": { + "gtag": "readonly" + }, "extends": ["eslint:recommended", "prettier"], "ignorePatterns": ["dist", "node_modules", "bin"] } diff --git a/packages/web/playwright.config.js b/packages/web/playwright.config.js index 03f3d145..4f1d74e4 100644 --- a/packages/web/playwright.config.js +++ b/packages/web/playwright.config.js @@ -39,7 +39,7 @@ module.exports = defineConfig({ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: 'on-first-retry', }, - + timeout: 60000, /* Configure projects for major browsers */ projects: [ { diff --git a/packages/web/src/scripts/consent-banner.js b/packages/web/src/scripts/consent-banner.js new file mode 100644 index 00000000..f02cc281 --- /dev/null +++ b/packages/web/src/scripts/consent-banner.js @@ -0,0 +1,57 @@ +const analyticsAcceptBtn = document.getElementById("accept-analytics-btn") +const analyticsDeclineBtn = document.getElementById("decline-analytics-btn") +const analyticsBanner = document.getElementById("analytics-banner") +const manageAnalyticsLink = document.getElementById("consent-link") + +// Show banner if the user has not set any preference +if(localStorage.getItem('consentMode') === null){ + analyticsBanner.classList.remove("hidden") +} + +// Open analytics banner when clicking on the cookie manager link +manageAnalyticsLink.addEventListener("click", () => { + analyticsBanner.classList.remove("hidden") +}) + +//If analytics are rejected, close the banner and update the new user preference +analyticsDeclineBtn.addEventListener("click", () => { + analyticsBanner.classList.add("hidden") + + const consentPreferences = { + 'ad_storage': 'denied', + 'analytics_storage': 'denied', + 'ad_user_data': 'denied', + 'ad_personalization': 'denied', + 'personalization_storage': 'denied', + 'functionality_storage': 'denied', + 'security_storage': 'denied', + } + + updateConsentMode(consentPreferences) +}) + +//If analytics are accepted, close the banner and update the new user preference +analyticsAcceptBtn.addEventListener("click", () => { + analyticsBanner.classList.add("hidden") + + const consentPreferences = { + 'ad_storage': 'denied', + 'analytics_storage': 'granted', + 'ad_user_data': 'denied', + 'ad_personalization': 'denied', + 'personalization_storage': 'denied', + 'functionality_storage': 'denied', + 'security_storage': 'denied', + } + + updateConsentMode(consentPreferences) +}) + +/** + * Update the user preferences to grant or decline google analytics tracking + * @param { Object } preferences + */ +function updateConsentMode(preferences) { + gtag('consent', 'update', preferences) + localStorage.setItem('consentMode', JSON.stringify(preferences)) +} \ No newline at end of file diff --git a/packages/web/src/scripts/main.js b/packages/web/src/scripts/main.js index 45ceb027..2a1bcd96 100644 --- a/packages/web/src/scripts/main.js +++ b/packages/web/src/scripts/main.js @@ -20,6 +20,7 @@ * to integrate the monaco editor */ +import './consent-banner' import './visualize' import './editor' import './json-yaml' @@ -34,6 +35,7 @@ import './defaults' import './visualize' import './validation' + /***********************************************************/ /* Loader */ /***********************************************************/ diff --git a/packages/web/src/styles/_settings-menu.scss b/packages/web/src/styles/_settings-menu.scss index 5e2140df..e2e97362 100644 --- a/packages/web/src/styles/_settings-menu.scss +++ b/packages/web/src/styles/_settings-menu.scss @@ -31,7 +31,7 @@ flex-direction: column; align-items: center; justify-content: space-between; - z-index: 100; + z-index: 80; .settings { width: 100%; @@ -72,7 +72,6 @@ label { font-size: var(--fs-p); - max-width: 10ch; } input[type="checkbox"] { @@ -261,17 +260,21 @@ display: flex; flex-direction: row; align-items: center; - justify-content: center; + justify-content: space-between; a { - font-size: 1.2rem; - padding: 0 1rem; + font-size: 1.1rem; height: 100%; + text-wrap: nowrap; + font-weight: bold; + } - &:not(:last-child) { - border-right: 1px solid var(--clr-neutral-50); - } + &__separator { + height: 1rem; + width: 1px; + background-color: var(--clr-neutral-50); + margin: 0 .5rem; } } } diff --git a/packages/web/src/styles/styles.scss b/packages/web/src/styles/styles.scss index f471de69..94d6552f 100644 --- a/packages/web/src/styles/styles.scss +++ b/packages/web/src/styles/styles.scss @@ -40,18 +40,23 @@ --fs-h2: 2.5rem; --fs-sub-header: 1.75rem; --fs-p: 1.2rem; + --fs-p-lg: 1.4rem; --fs-footer: 0.8rem; --fs-i: 1.4rem; - //responsive font size values - --fs-h1: clamp(2rem, 1.8rem + 0.625vw, 3rem); - --fs-h2: clamp(2rem, 1.8rem + 0.625vw, 3rem); - --fs-h3: clamp(1.5rem, 1.3rem + 0.625vw, 2.5rem); + //Fluid typography values + //Min viewport value: 20rem = 320px + //Max viewport value: 120rem = 1920px + --fs-h1: clamp(2rem, 1.8rem + 1vw, 3rem); + --fs-h2: clamp(2rem, 1.8rem + 1vw, 3rem); + --fs-h3: clamp(1.5rem, 1.3rem + 1vw, 2.5rem); --fs-sub-header: clamp(1.25rem, 1.05rem + 1vw, 2.25rem); --fs-p: clamp(1rem, 0.92rem + 0.3999999999999999vw, 1.4rem); + --fs-p-lg: clamp(1.2rem, 1.1199999999999999rem + 0.4000000000000002vw, 1.6rem); --fs-footer: clamp(0.8rem, 0.76rem + 0.19999999999999996vw, 1rem); --fs-i: clamp(1.2rem, 1.04rem + 0.8vw, 2rem); + /*line heights*/ --lh-h1: 4rem; --lh-h2: 4rem; @@ -405,6 +410,78 @@ main { } } +/** Analytics banner **/ +.analytics-banner{ + position: fixed; + bottom: 0; + left: 0; + z-index: 90; + width: 100%; + padding: 2rem; + background-color: var(--clr-neutral-900); + box-shadow: 0px -5px 10px var(--clr-neutral-300); + + &.hidden { + display: none; + } + + &__disclaimer { + color: var(--clr-neutral-100); + + & > *{ + margin-bottom: 1rem; + } + + h4 { + font-size: var(--fs-h2); + color: var(--clr-controls-bg); + } + + p { + font-size: var(--fs-p-lg); + + a { + text-decoration: underline; + color: var(--clr-neutral-300); + } + } + } + + &__interactions { + margin-top: 2rem; + + button { + appearance:unset; + padding: 0.75rem 1.75rem; + min-width: 9rem; + cursor: pointer; + border: 2px solid var(--clr-controls-bg); + border-radius: 5px; + font-size: var(--fs-p-lg); + font-weight: var(--fw-bold); + transition: all 250ms ease-in-out; + + &.decline-btn { + background-color: transparent; + color: var(--clr-controls-bg); + margin-right: 1rem; + } + + &.accept-btn{ + background-color: var(--clr-controls-bg); + color: var(--clr-controls); + } + + &:hover { + background-color: var(--clr-primary-900); + color: var(--clr-controls); + border-color: var(--clr-primary-900); + } + } + + } +} + @keyframes fade{ 0%{opacity: 1;} 50%{opacity: .1;} diff --git a/packages/web/src/template.html b/packages/web/src/template.html index 874d8894..c0d28571 100644 --- a/packages/web/src/template.html +++ b/packages/web/src/template.html @@ -37,6 +37,36 @@ + + + + + + + @@ -974,21 +1004,27 @@

Preferences

@@ -1149,8 +1185,22 @@

Warning!

- + + \ No newline at end of file diff --git a/packages/web/tests/test.spec.js b/packages/web/tests/test.spec.js index e6110f30..97e448f8 100644 --- a/packages/web/tests/test.spec.js +++ b/packages/web/tests/test.spec.js @@ -19,6 +19,50 @@ test.beforeEach(async ({ page }) => { await page.goto('/') }); +//Check the initial state of the consent banner and close it to allow the other tests to run +test.beforeEach(async ({ page }) => { + + // Before the user sets any preferences the local storage should be empty + const localStorageBefore = await page.evaluate(() => { + return JSON.stringify(localStorage); + }); + + expect(localStorageBefore).toEqual("{}") + + //If the user hasn't set any preference (local storage is empty) then the default value for the gtag should all be set to denied + const gtagDefaultValue = await page.evaluate(() => { + return window.dataLayer[0]['2']; + }); + + expect(gtagDefaultValue).toEqual({ + 'ad_storage': 'denied', + 'analytics_storage': 'denied', + 'ad_user_data': 'denied', + 'ad_personalization': 'denied', + 'personalization_storage': 'denied', + 'functionality_storage': 'denied', + 'security_storage': 'denied' + }) + + //If no preferences set, then the banner should always be shown to the user + const consentBanner = page.locator('#analytics-banner') + await expect(consentBanner).toHaveClass("analytics-banner") + + //After setting the preferences the banner should be hidden + const declineAnalyticsBtn = page.locator('#decline-analytics-btn') + await declineAnalyticsBtn.click() + + await expect(consentBanner).toHaveClass("analytics-banner hidden") + + // If the user declined the consent, the new preference is saved in the local storage with every tags set to denied + const localStorageAfter = await page.evaluate(() => { + return JSON.stringify(localStorage); + }); + + const declinedObject = { "consentMode": "{\"ad_storage\":\"denied\",\"analytics_storage\":\"denied\",\"ad_user_data\":\"denied\",\"ad_personalization\":\"denied\",\"personalization_storage\":\"denied\",\"functionality_storage\":\"denied\",\"security_storage\":\"denied\"}" } + expect(localStorageAfter).toEqual(JSON.stringify(declinedObject)) +}); + test.describe("Load initial state", () => { test('Has title', async ({ page }) => { await expect(page).toHaveTitle("TD Playground") @@ -129,6 +173,55 @@ test.describe("Check all links", () => { }) }) +test.describe("Consent banner interactions", () => { + test("Open consent banner and accept analytics", async ({ page }) => { + // Check for the user preferences in the local storage + const localStorageBefore = await page.evaluate(() => { + return JSON.stringify(localStorage); + }); + + //Since before all the tests start the consent banner has already been declined the default consent object in the localStorage should already be set to deny all + const declinedObject = { "consentMode": "{\"ad_storage\":\"denied\",\"analytics_storage\":\"denied\",\"ad_user_data\":\"denied\",\"ad_personalization\":\"denied\",\"personalization_storage\":\"denied\",\"functionality_storage\":\"denied\",\"security_storage\":\"denied\"}" } + expect(localStorageBefore).toEqual(JSON.stringify(declinedObject)) + + //Check that the banner is hidden + const consentBanner = page.locator('#analytics-banner') + await expect(consentBanner).toHaveClass("analytics-banner hidden") + + // Open settings to access the consent management link and open the consent banner + await page.locator('#settings-btn').click() + await page.locator('#consent-link').click() + await expect(consentBanner).toHaveClass("analytics-banner") + + //Accept analytics and hide banner + const acceptAnalyticsBtn = page.locator('#accept-analytics-btn') + await acceptAnalyticsBtn.click() + await expect(consentBanner).toHaveClass("analytics-banner hidden") + + //After accepting analytics, the preference should be saved in the local storage as well as updated in the gtag from the window dataLayer + const allowedObject = { "consentMode": "{\"ad_storage\":\"denied\",\"analytics_storage\":\"granted\",\"ad_user_data\":\"denied\",\"ad_personalization\":\"denied\",\"personalization_storage\":\"denied\",\"functionality_storage\":\"denied\",\"security_storage\":\"denied\"}" } + const localStorageAfter = await page.evaluate(() => { + return JSON.stringify(localStorage); + }); + + expect(localStorageAfter).toEqual(JSON.stringify(allowedObject)) + + const gtagUpdatedValue = await page.evaluate(() => { + return window.dataLayer[8]["2"]; + }); + + expect(gtagUpdatedValue).toEqual({ + 'ad_storage': 'denied', + 'analytics_storage': 'granted', + 'ad_user_data': 'denied', + 'ad_personalization': 'denied', + 'personalization_storage': 'denied', + 'functionality_storage': 'denied', + 'security_storage': 'denied' + }) + }) +}) + test.describe("Editors and Tabs creation and deletion", () => { test("Adding a new editor and closing it", async ({ page }) => { const editorTabs = page.locator("#tab") @@ -665,7 +758,7 @@ test.describe("Settings menu functionality", () => { const fontSizeSlider = page.locator('#font-size') await fontSizeSlider.click() - await expect(editorFontSize).toHaveText("23") + await expect(editorFontSize).toHaveText("23") await page.reload({ waitUntil: 'domcontentloaded' })