Skip to content

Commit

Permalink
Feat/self serve branding (#1764)
Browse files Browse the repository at this point in the history
* feat(branding): add new placeholder pages

* style(branding): add info icon

* feat(branding): add new routes/logic for branding

* feat(branding): add feature flag to allow branding settings link to flow to new feature

* feat(branding): add new form for GOC branding selection

* chore(branding): re-gen index.css

* chore: formatting

* feat(back navigation): add js helper to make back links all work without keeping track

* feat(branding form model): simplify model so it can be used in both the GOC and pool pages

* feat(branding preview): add flexibility to preview current branding or arbitrary branding

* feat(preview): add a partial using shadow dom to isolate and display email template preview

* feat(branding): move display of branding into its own partial

* feat(view branding): add preview option to settings page

* feat(goc branding): use branding partial

* feat(pool): add ability to select from other branding in org; add empty state;

* feat(preview): add preview page

* feat(branding request): get the page structure ready

* feat(view branding): add preview to settings

* chore: formatting

* chore: formatting

* chore: regen css

* chore(branding request): add WTF form for branding request page

* feat(branding request): add form to page

* feat(branding request): experiment with building a better file upload component

* chore: regen css

* feat(branding_request): make a proper js component; refactor page to suit

* feat(request_branding): add backend code i missed

* a11y(branding_request): focus on preview after file upload

* feat(branding_request): make request post work

* chore(css/forms): update css and use form macros

* chore: formatting

* style(radio image): update padding

* feat(request submit): fix up confirmation page/forms

* chore: regen css

* chore: formatting

* test(branding): add tests for `/edit-branding` and `/review-pool`

* chore: remove unused component

* feat(test-ids): update some macros to accept testid param

* feat(branding): add testids to ui elements

* chore: formatting

* secure(request_branding): encode URI before using it

* fix(pool): clean up forms; implement latest content/design

* a11y(select-input): divs arent allowed inside labels

* style(branding-settings): update/simplify as per latest design

* style(preview): update per latest design

* style(branding): update as per latest design

* chore: regen css

* chore: formatting

* test(test_headers): update test with new CSP

* chore: formatting

* fix(branding): update failing tests

* fix: add placeholder for missing translations

* fix(a11y): fix floating quotation mark that breaks html validation

* style(branding preview): show placeholder rectangles instead of content

for a11y, provide useful content!

* chore: formatting

* chore: update tranlsations

* fix:(request branding): update screen per figma

* chore: update validation message in test

* chore: add missing translation

* feat(request branding): update screen/behaviour as per figma

* chore: formatting

* chore: add translation

* chore: remove unsused variable; remove commented out code

* chore: remove dupe translation

* chore: adding docstrings

* fix: enable new branding features for all platform admins

* chore: formatting/regen js

* chore: docs

* chore: regen css/js

* chore: formatting

* fix: remove testing FF from staging config
  • Loading branch information
andrewleith authored Mar 21, 2024
1 parent b3b2920 commit f14446b
Show file tree
Hide file tree
Showing 33 changed files with 1,100 additions and 31 deletions.
2 changes: 1 addition & 1 deletion app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -665,7 +665,7 @@ def useful_headers_after_request(response):
"object-src 'self';"
f"style-src 'self' fonts.googleapis.com https://tagmanager.google.com https://fonts.googleapis.com 'unsafe-inline';"
f"font-src 'self' {asset_domain} fonts.googleapis.com fonts.gstatic.com *.gstatic.com data:;"
f"img-src 'self' {asset_domain} *.canada.ca *.cdssandbox.xyz *.google-analytics.com *.googletagmanager.com *.notifications.service.gov.uk *.gstatic.com https://siteintercept.qualtrics.com data:;" # noqa: E501
f"img-src 'self' blob: {asset_domain} *.canada.ca *.cdssandbox.xyz *.google-analytics.com *.googletagmanager.com *.notifications.service.gov.uk *.gstatic.com https://siteintercept.qualtrics.com data:;" # noqa: E501
"frame-ancestors 'self';"
"form-action 'self' *.siteintercept.qualtrics.com https://siteintercept.qualtrics.com;"
"frame-src 'self' www.googletagmanager.com https://cdssnc.qualtrics.com/;"
Expand Down
163 changes: 163 additions & 0 deletions app/assets/javascripts/branding_request.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/**
* This script handles the functionality for the branding request form.
* It initializes the UI, updates the email template preview based on the selected file,
* and validates the form inputs (brand name and logo).
*
* Client-side validation:
* ----------------------
* Normally with file uploads, when you post a file it doesnt get returned back with the form, so if we want to display
* errors, we would have to either save the file temporarily (i.e. in s3) which is complex or force the user
* to upload it twice, which isn't great UX.
*
* Instead, we do client-side validation so that the user can upload the file only once.
*
* Localisation:
* ------------
* This module uses the window.APP_PHRASES object to get the strings for the error messages, following the pattern of other
* existing components.
*
*/
(function () {
"use strict";
const input_img = document.querySelector("input.file-upload-field");
const input_brandname = document.querySelector("#name");
const submit_button = document.querySelector("button[type=submit]");
const message = document.querySelector(".preview .message");
const image_slot = document.querySelector(".preview .img");
const preview_heading = document.querySelector("#preview_heading");
const brand_name_group = document
.querySelector("#name")
.closest(".form-group");
const image_label = document.getElementById("file-upload-label");

// init UI
input_img.style.opacity = 0;
input_img.addEventListener("change", updateImageDisplay);
submit_button.addEventListener("click", validateForm);
input_brandname.addEventListener("change", validateBrand);
// strings
let file_name = window.APP_PHRASES.branding_request_file_name;
let file_size = window.APP_PHRASES.branding_request_file_size;
let display_size = window.APP_PHRASES.branding_request_display_size;
let brand_error = window.APP_PHRASES.branding_request_brand_error;
let logo_error = window.APP_PHRASES.branding_request_logo_error;

// error message html
let brand_error_html = `<span id="name-error-message" data-testid="brand-error" class="error-message" data-module="track-error" data-error-type="${brand_error}" data-error-label="name">${brand_error}</span>`;
let image_error_html = `<span id="logo-error-message" data-testid="logo-error" class="error-message">${logo_error}</span>`;

/**
* Update email template preview based on the selected file
*/
function updateImageDisplay() {
// remove the last image
while (image_slot.firstChild) {
image_slot.removeChild(image_slot.firstChild);
}

const curFiles = input_img.files;
if (curFiles.length === 0) {
const message = document.createElement("p");
message.textContent = "No files currently selected for upload";
} else {
for (const file of curFiles) {
if (validFileType(file)) {
const img_src = URL.createObjectURL(file);
const img = document
.getElementById("template_preview")
.shadowRoot.querySelector("img");
img.src = encodeURI(img_src);

img.onload = () => {
message.textContent = `${file_name} ${
file.name
}, ${file_size} ${returnFileSize(file.size)}, ${display_size} ${
img.naturalWidth
} x ${img.naturalHeight}`;
document
.querySelector(".template_preview")
.classList.remove("hidden");
preview_heading.focus();
};
validateLogo();
} else {
//remove file from input
input_img.value = "";
}
}
}
}

/**
* Utilities
*/
const fileTypes = ["image/png"];

function validFileType(file) {
return fileTypes.includes(file.type);
}

function returnFileSize(number) {
if (number < 1024) {
return `${number} bytes`;
} else if (number >= 1024 && number < 1048576) {
return `${(number / 1024).toFixed(1)} KB`;
} else if (number >= 1048576) {
return `${(number / 1048576).toFixed(1)} MB`;
}
}

function validateForm(event) {
const brandName = input_brandname.value.trim();
const image = input_img.value.length > 0;

validateBrand();
validateLogo();

if (!brandName || !image) {
// set focus on the first input with an error
if (!brandName) {
input_brandname.focus();
} else {
input_img.focus();
}
if (event) {
event.preventDefault();
}
}
}

function validateBrand() {
const brandName = input_brandname.value.trim();

if (!brandName) {
if (!brand_name_group.classList.contains("form-group-error")) {
// dont display the error more than once
brand_name_group.classList.add("form-group-error");
input_brandname.insertAdjacentHTML("beforebegin", brand_error_html);
}
} else {
if (brand_name_group.classList.contains("form-group-error")) {
brand_name_group.classList.remove("form-group-error");
document.getElementById("name-error-message").remove();
}
}
}

function validateLogo() {
const image = input_img.value.length > 0;

if (!image) {
if (!image_label.classList.contains("form-group-error")) {
// dont display the error more than once
image_label.classList.add("form-group-error");
image_label.insertAdjacentHTML("beforebegin", image_error_html);
}
} else {
if (image_label.classList.contains("form-group-error")) {
image_label.classList.remove("form-group-error");
document.getElementById("logo-error-message").remove();
}
}
}
})();
1 change: 1 addition & 0 deletions app/assets/javascripts/branding_request.min.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions app/assets/javascripts/fontawesome.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { faAngleDown } from "@fortawesome/free-solid-svg-icons/faAngleDown";
import { faCircleQuestion } from "@fortawesome/free-solid-svg-icons/faCircleQuestion";
import { faTriangleExclamation } from "@fortawesome/free-solid-svg-icons/faTriangleExclamation";
import { faCircleExclamation } from "@fortawesome/free-solid-svg-icons/faCircleExclamation";
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";

let FontAwesomeIconLoader = () => {
config.autoAddCss = false;
Expand All @@ -26,6 +27,7 @@ let FontAwesomeIconLoader = () => {
faCircleQuestion,
faTriangleExclamation,
faCircleExclamation,
faInfoCircle,
]);
dom.watch();
};
Expand Down
2 changes: 1 addition & 1 deletion app/assets/javascripts/main.min.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion app/assets/javascripts/scheduler.min.js

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions app/assets/javascripts/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,16 @@
});
}

/**
* Make branding links automatically go back to the previous page without keeping track of them
*/
document
.querySelector(".branding .back-link")
?.addEventListener("click", function (e) {
e.preventDefault();
window.history.back();
});

global.utils = {
registerKeyDownEscape: registerKeyDownEscape,
};
Expand Down
2 changes: 1 addition & 1 deletion app/assets/stylesheets/index.css

Large diffs are not rendered by default.

78 changes: 78 additions & 0 deletions app/assets/stylesheets/tailwind/elements/form-multiple-choice.css
Original file line number Diff line number Diff line change
Expand Up @@ -100,5 +100,83 @@
@apply mb-0 mr-gutter;
}
}
/* When multiple choice has an image */
fieldset:has(img) {
@apply grid gap-gutterHalf;
}
fieldset:has(img):has(.multiple-choice:nth-of-type(5)) {
@apply grid gap-gutterHalf;
}
@screen md {
fieldset:has(img):has(.multiple-choice:nth-of-type(5)) {
@apply grid-cols-2;
}
}

.multiple-choice:has(img) {
--multiple-choice-img-padding: 1.5rem;
padding-left: calc(38px + var(--multiple-choice-img-padding));
padding-top: var(--multiple-choice-img-padding);
max-width: 600px;
@apply border-2 border-gray-300 m-0;
}
.multiple-choice:has(img):hover {
@apply border-gray-500;
}

.multiple-choice:has(img) [type="radio"] {
width: 100%;
height: 100%;
top: 0;
left: 0;
}
.multiple-choice:has(img) [type="radio"] + label {
padding-bottom: var(--multiple-choice-img-padding);
padding-right: var(--multiple-choice-img-padding);
}

.multiple-choice:has(img) [type="radio"] + label::before {
top: var(--multiple-choice-img-padding);
left: var(--multiple-choice-img-padding);
@apply bg-white;
}
.multiple-choice:has(img) [type="radio"] + label::after {
top: calc(9px + var(--multiple-choice-img-padding));
left: calc(9px + var(--multiple-choice-img-padding));
}
.multiple-choice:has(img):focus-within {
box-shadow: 0 2px 0 0 black;
@apply bg-yellow border-yellow;
}
.multiple-choice:has(img):has(input:checked) {
box-shadow: none;
@apply border-black;
}

.multiple-choice:has(img) img {
max-height: 108px;
background-size: 10px 10px;
background-position:
0 0,
5px 5px;
background-image: linear-gradient(
45deg,
#cfd5dd 25%,
transparent 25%,
transparent 75%,
#cfd5dd 75%,
#cfd5dd
),
linear-gradient(
45deg,
#cfd5dd 25%,
transparent 25%,
transparent 75%,
#cfd5dd 75%,
#cfd5dd
);
@apply border-1 border-gray-300 bg-white mt-gutterHalf;
}

/*! purgecss end ignore */
}
1 change: 1 addition & 0 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ class Config(object):
# FEATURE FLAGS
FF_SALESFORCE_CONTACT = env.bool("FF_SALESFORCE_CONTACT", False)
FF_SALESFORCE_CONTACT = env.bool("FF_SALESFORCE_CONTACT", False)
FF_NEW_BRANDING = env.bool("FF_NEW_BRANDING", False)

@classmethod
def get_sensitive_config(cls) -> list[str]:
Expand Down
59 changes: 58 additions & 1 deletion app/main/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -1170,7 +1170,7 @@ class ServiceUpdateEmailBranding(StripWhitespaceForm):
)
],
)
file = FileField_wtf("Upload a PNG logo", validators=[FileAllowed(["png"], "PNG Images only!")])
file = FileField_wtf("Upload a PNG logo")
brand_type = RadioField(
"Brand type",
choices=[
Expand Down Expand Up @@ -1757,3 +1757,60 @@ class GoLiveAboutNotificationsFormNoOrg(GoLiveAboutServiceFormNoOrg):
],
validators=[DataRequired()],
)


class BrandingGOCForm(StripWhitespaceForm):
"""
Form for selecting logo from GOC options
Attributes:
goc_branding (RadioField): Field for entering the the logo
"""

goc_branding = RadioField(
_l("Choose which language shows first <span class='sr-only'>&nbsp;used in the Government of Canada signature</span>"),
choices=[ # Choices by default, override to get more refined options.
(FieldWithLanguageOptions.ENGLISH_OPTION_VALUE, _l("English-first")),
(FieldWithLanguageOptions.FRENCH_OPTION_VALUE, _l("French-first")),
],
validators=[DataRequired(message=_l("You must select an option to continue"))],
)

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)


class BrandingPoolForm(StripWhitespaceForm):
"""
Form for selecting alternate branding logo from a pool of options associated with the service's organisation.
Attributes:
pool_branding (RadioField): Field for entering the the logo
"""

pool_branding = RadioField(
_l("Select alternate logo"),
choices=[], # Choices by default, override to get more refined options.
validators=[DataRequired(message=_l("You must select an option to continue"))],
)

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)


class BrandingRequestForm(StripWhitespaceForm):
"""
Form for handling new branding requests.
Attributes:
name (StringField): Field for entering the name of the logo.
file (FileField_wtf): Field for uploading the logo file.
"""

name = StringField(label=_l("Name of logo"), validators=[DataRequired(message=_l("Enter the name of the logo"))])
file = FileField_wtf(
label=_l("Prepare your logo"),
validators=[
DataRequired(message=_l("You must select a file to continue")),
],
)
Loading

0 comments on commit f14446b

Please sign in to comment.