Skip to content

Commit

Permalink
Implement new layout for the sponsorship application (#1744)
Browse files Browse the repository at this point in the history
* Order benefits by amount of associated packages

* Minimal HTML logic to control program and benefits exhibition

* Better display package inputs

* Allow the benefits table to overflow and have a width greater than the article

* Remove sponsorship substring from program name

* Display checkbox if it's a potential add-on

* Separate add-ons benefits from packages' ones

* Display add-on as checkbox in the frontend + row/col css refactoring

* Add headers with form sections titles

* Bass CSS defined in the specs

* Section titles styling

* Initial benefits table styling

* Final styles to packages/benefits table

* Enable filter by package as well

* Style add-ons cards

* Add styles to submit section

* Style section titles

* Run black

* Implement sponsorship's footer

* Remove legacy benefits input fields and styles

* Move thank you section closer to footer

* Simplify JS code to only select benefits after package selection

* Replace png by svg img

* Add selected CSS

* Display selected packages when page loads

* Clean-up add-ons selectons after changing the package

* Potential add-on checkboxes updating django's form related ones.

* Hide inputs

This is not a problem because the backend code runs all validations needed as well

* Display form errors

* Surrounds package radio inputs in a div

* Style package selection input

* Add event to handle click on package input container

* Add mobile style to add-on selection

* Style form submit section form moble

* Style benefits list for the mobile version

* Refactor and load ticks on mobile if form has initial data

* Make sure add-on benefits are being saved

* Get value only if checked inputs

* compile scss

* Replace add-on input by images

* Reset add-on image if package changes

* Enable user to unselect benefits as well

* Display image considering form initial

* Add-ons considering form initial

* Prevent package names from overlapping

* Respect initial values for potential add-ons

* Update sponsors/forms.py

Co-authored-by: Éric Araujo <merwok@netwok.org>

* perhaps yuglify isn't necessary?

* noop compressor

* trying to debug deployed js

* disable wrapping Javascript

* Revert "trying to debug deployed js"

This reverts commit 178d8aa.

* display/bolden sponsorship program title

Co-authored-by: Ernest W. Durbin III <ewdurbin@gmail.com>
Co-authored-by: Éric Araujo <merwok@netwok.org>
  • Loading branch information
3 people committed Apr 6, 2021
1 parent 7d00625 commit 159f0bb
Show file tree
Hide file tree
Showing 18 changed files with 836 additions and 335 deletions.
5 changes: 3 additions & 2 deletions pydotorg/settings/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,15 +73,16 @@
PIPELINE = {
'STYLESHEETS': PIPELINE_CSS,
'JAVASCRIPT': PIPELINE_JS,
'DISABLE_WRAPPER': True,
# TODO: ruby-sass is not installed on the server since
# https://github.com/python/psf-salt/commit/044c38773ced4b8bbe8df2c4266ef3a295102785
# and we pre-compile SASS files and commit them into codebase so we
# don't really need this. See issue #832.
# 'COMPILERS': (
# 'pipeline.compilers.sass.SASSCompiler',
# ),
'CSS_COMPRESSOR': 'pipeline.compressors.yuglify.YuglifyCompressor',
'JS_COMPRESSOR': 'pipeline.compressors.yuglify.YuglifyCompressor',
'CSS_COMPRESSOR': 'pipeline.compressors.NoopCompressor',
'JS_COMPRESSOR': 'pipeline.compressors.NoopCompressor',
# 'SASS_BINARY': 'cd %s && exec /usr/bin/env sass' % os.path.join(BASE, 'static'),
# 'SASS_ARGUMENTS': '--quiet --compass --scss -I $(dirname $(dirname $(gem which susy)))/sass'
}
2 changes: 1 addition & 1 deletion sponsors/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ class SponsorshipBenefitAdmin(OrderedModelAdmin):
"internal_value",
"move_up_down_links",
]
list_filter = ["program", "package_only"]
list_filter = ["program", "package_only", "packages"]
search_fields = ["name"]

fieldsets = [
Expand Down
12 changes: 10 additions & 2 deletions sponsors/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from django.utils.translation import ugettext_lazy as _
from django.utils.functional import cached_property
from django.conf import settings
from django.db.models import Count

from sponsors.models import (
SponsorshipBenefit,
Expand Down Expand Up @@ -48,16 +49,23 @@ class SponsorshiptBenefitsForm(forms.Form):
required=False,
empty_label=None,
)
add_ons_benefits = PickSponsorshipBenefitsField(
required=False,
queryset=SponsorshipBenefit.objects.add_ons().select_related("program"),
)

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
benefits_qs = SponsorshipBenefit.objects.select_related("program")
benefits_qs = SponsorshipBenefit.objects.with_packages().select_related(
"program"
)

for program in SponsorshipProgram.objects.all():
slug = slugify(program.name).replace("-", "_")
self.fields[f"benefits_{slug}"] = PickSponsorshipBenefitsField(
queryset=benefits_qs.filter(program=program),
required=False,
label=_(f"{program.name} Sponsorship Benefits"),
label=_("{program_name} Benefits").format(program_name=program.name),
)

@property
Expand Down
12 changes: 11 additions & 1 deletion sponsors/models.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from itertools import chain
from django.conf import settings
from django.db import models
from django.db.models import Sum
from django.db.models import Sum, Count
from django.template.defaultfilters import truncatechars
from django.utils import timezone
from django.utils.functional import cached_property
Expand Down Expand Up @@ -83,6 +83,16 @@ def with_conflicts(self):
def without_conflicts(self):
return self.filter(conflicts__isnull=True)

def add_ons(self):
return self.annotate(num_packages=Count("packages")).filter(num_packages=0)

def with_packages(self):
return (
self.annotate(num_packages=Count("packages"))
.exclude(num_packages=0)
.order_by("-num_packages")
)


class SponsorshipBenefit(OrderedModel):
objects = SponsorshipBenefitManager()
Expand Down
21 changes: 18 additions & 3 deletions sponsors/tests/test_forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
from sponsors.forms import (
SponsorshiptBenefitsForm,
SponsorshipApplicationForm,
SponsorContact,
Sponsor,
SponsorContactFormSet,
SponsorBenefitAdminInlineForm,
Expand All @@ -27,21 +26,36 @@ def setUp(self):
self.program_2_benefits = baker.make(
SponsorshipBenefit, program=self.wk, _quantity=5
)
self.package = baker.make("sponsors.SponsorshipPackage")
self.package.benefits.add(*self.program_1_benefits)
self.package.benefits.add(*self.program_2_benefits)

# packages without associated packages
self.add_ons = baker.make(SponsorshipBenefit, program=self.psf, _quantity=2)

def test_benefits_organized_by_program(self):
form = SponsorshiptBenefitsForm()

choices = list(form.fields["add_ons_benefits"].choices)

self.assertEqual(len(self.add_ons), len(choices))
for benefit in self.add_ons:
self.assertIn(benefit.id, [c[0] for c in choices])

def test_specific_field_to_select_add_ons(self):
form = SponsorshiptBenefitsForm()

field1, field2 = sorted(form.benefits_programs, key=lambda f: f.name)

self.assertEqual("benefits_psf", field1.name)
self.assertEqual("PSF Sponsorship Benefits", field1.label)
self.assertEqual("PSF Benefits", field1.label)
choices = list(field1.field.choices)
self.assertEqual(len(self.program_1_benefits), len(choices))
for benefit in self.program_1_benefits:
self.assertIn(benefit.id, [c[0] for c in choices])

self.assertEqual("benefits_working_group", field2.name)
self.assertEqual("Working Group Sponsorship Benefits", field2.label)
self.assertEqual("Working Group Benefits", field2.label)
choices = list(field2.field.choices)
self.assertEqual(len(self.program_2_benefits), len(choices))
for benefit in self.program_2_benefits:
Expand Down Expand Up @@ -83,6 +97,7 @@ def test_benefits_conflicts_helper_property(self):
def test_invalid_form_if_any_conflict(self):
benefit_1 = baker.make("sponsors.SponsorshipBenefit", program=self.wk)
benefit_1.conflicts.add(*self.program_1_benefits)
self.package.benefits.add(benefit_1)

data = {"benefits_psf": [b.id for b in self.program_1_benefits]}
form = SponsorshiptBenefitsForm(data=data)
Expand Down
12 changes: 9 additions & 3 deletions sponsors/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,13 @@ def setUp(self):
SponsorshipBenefit, program=self.wk, _quantity=5
)
self.package = baker.make("sponsors.SponsorshipPackage")
for benefit in self.program_1_benefits:
benefit.packages.add(self.package)
self.package.benefits.add(*self.program_1_benefits)
package_2 = baker.make("sponsors.SponsorshipPackage")
package_2.benefits.add(*self.program_2_benefits)
self.add_on_benefits = baker.make(
SponsorshipBenefit, program=self.psf, _quantity=2
)

self.user = baker.make(settings.AUTH_USER_MODEL, is_staff=True, is_active=True)
self.client.force_login(self.user)

Expand All @@ -54,6 +59,7 @@ def setUp(self):
self.data = {
"benefits_psf": [b.id for b in self.program_1_benefits],
"benefits_working_group": [b.id for b in self.program_2_benefits],
"add_ons_benefits": [b.id for b in self.add_on_benefits],
"package": self.package.id,
}

Expand All @@ -73,7 +79,7 @@ def test_display_template_with_form_and_context(self):
self.assertTemplateUsed(r, "sponsors/sponsorship_benefits_form.html")
self.assertIsInstance(r.context["form"], SponsorshiptBenefitsForm)
self.assertEqual(r.context["benefit_model"], SponsorshipBenefit)
self.assertEqual(3, packages.count())
self.assertEqual(4, packages.count())
self.assertIn(psf_package, packages)
self.assertIn(extra_package, packages)
self.assertEqual(
Expand Down
4 changes: 3 additions & 1 deletion sponsors/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,9 @@ def _set_form_data_cookie(self, form, response):
"package": "" if not form.get_package() else form.get_package().id,
}
for fname, benefits in [
(f, v) for f, v in form.cleaned_data.items() if f.startswith("benefits_")
(f, v)
for f, v in form.cleaned_data.items()
if f.startswith("benefits_") or f == 'add_ons_benefits'
]:
data[fname] = sorted([b.id for b in benefits])

Expand Down
Binary file added static/img/sponsors/tick-placeholder.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions static/img/sponsors/tick.svg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added static/img/sponsors/title-1.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added static/img/sponsors/title-2.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added static/img/sponsors/title-3.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added static/img/sponsors/title-4.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
172 changes: 79 additions & 93 deletions static/js/sponsors/applicationForm.js
Original file line number Diff line number Diff line change
@@ -1,103 +1,89 @@
$(document).ready(function(){
const SELECTORS = {
checkboxesContainer: function() { return $("#benefits_container"); },
costLabel: function() { return $("#cost_label"); },
clearFormBtn: function() { return $("#clear_form_btn"); },
packageInput: function() { return $("input[name=package]"); },
applicationForm: function() { return $("#application_form"); },
getPackageInfo: function(packageId) { return $("#package_benefits_" + packageId); },
getPackageBenefits: function(packageId) { return SELECTORS.getPackageInfo(packageId).children(); },
benefitsInputs: function() { return $("input[id^=id_benefits_]"); },
getBenefitLabel: function(benefitId) { return $('label[benefit_id=' + benefitId + ']'); },
getBenefitInput: function(benefitId) { return SELECTORS.benefitsInputs().filter('[value=' + benefitId + ']'); },
getBenefitConflicts: function(benefitId) { return $('#conflicts_with_' + benefitId).children(); },
getSelectedBenefits: function() { return SELECTORS.benefitsInputs().filter(":checked"); },
}

displayPackageCost = function(packageId) {
let packageInfo = SELECTORS.getPackageInfo(packageId);
let cost = packageInfo.attr("data-cost");
SELECTORS.costLabel().html('Sponsorship cost is $' + cost.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",") + ' USD')
}



SELECTORS.clearFormBtn().click(function(){
SELECTORS.applicationForm().trigger("reset");
SELECTORS.applicationForm().find(".active").removeClass("active");
SELECTORS.packageInput().prop("checked", false);
SELECTORS.checkboxesContainer().find(':checkbox').each(function(){
$(this).prop('checked', false);
if ($(this).attr("package_only")) $(this).attr("disabled", true);
});
SELECTORS.costLabel().html("");
});
const SELECTORS = {
packageInput: function() { return $("input[name=package]"); },
getPackageInfo: function(packageId) { return $("#package_benefits_" + packageId); },
getPackageBenefits: function(packageId) { return SELECTORS.getPackageInfo(packageId).children(); },
benefitsInputs: function() { return $("input[id^=id_benefits_]"); },
getBenefitInput: function(benefitId) { return SELECTORS.benefitsInputs().filter('[value=' + benefitId + ']'); },
getSelectedBenefits: function() { return SELECTORS.benefitsInputs().filter(":checked"); },
tickImages: function() { return $(`.benefit-within-package img`) },
}

SELECTORS.packageInput().change(function(){
let package = this.value;
if (package.length == 0) return;

SELECTORS.costLabel().html("Updating cost...")

SELECTORS.checkboxesContainer().find(':checkbox').each(function(){
$(this).prop('checked', false);
let packageOnlyBenefit = $(this).attr("package_only");
if (packageOnlyBenefit) $(this).attr("disabled", true);
});

SELECTORS.getPackageBenefits(package).each(function(){
let benefit = $(this).html()
let benefitInput = SELECTORS.getBenefitInput(benefit);
let packageOnlyBenefit = benefitInput.attr("package_only");
benefitInput.removeAttr("disabled");
benefitInput.trigger("click");
});
displayPackageCost(package);
});
const initialPackage = $("input[name=package]:checked").val();
if (initialPackage && initialPackage.length > 0) mobileUpdate(initialPackage);

SELECTORS.benefitsInputs().change(function(){
let benefit = this.value;
if (benefit.length == 0) return;

// display package cost if custom benefit change result matches with package's benefits list
let isChangeFromPackageChange = SELECTORS.costLabel().html() == "Updating cost..."
if (!isChangeFromPackageChange) {
let selectedBenefits = SELECTORS.getSelectedBenefits();
selectedBenefits = $.map(selectedBenefits, function(b) { return $(b).val() }).sort();
let selectedPackageId = SELECTORS.packageInput().filter(":checked").val()
let packageBenefits = SELECTORS.getPackageBenefits(selectedPackageId);
packageBenefits = $.map(packageBenefits, function(b) { return $(b).text() }).sort();

// check same num of benefits and join with string. if same string, both lists have the same benefits
if (packageBenefits.length == selectedBenefits.length && packageBenefits.join(',') === selectedBenefits.join(',')){
displayPackageCost(selectedPackageId);
} else {
let msg = "Please submit your customized sponsorship package application and we'll contact you within 2 business days.";
SELECTORS.costLabel().html(msg);
}
}
SELECTORS.packageInput().click(function(){
let package = this.value;
if (package.length == 0) return;

// updates the input to be active if needed
let active = SELECTORS.getBenefitInput(benefit).prop("checked");
if (!active) {
return;
} else {
SELECTORS.getBenefitLabel(benefit).addClass("active");
// clear previous customizations
SELECTORS.tickImages().each((i, img) => {
const initImg = img.getAttribute('data-initial-state');
const src = img.getAttribute('src');

if (src !== initImg) {
img.setAttribute('data-next-state', src);
}

// check and ensure conflicts constraints between checked benefits
SELECTORS.getBenefitConflicts(benefit).each(function(){
let conflictId = $(this).html();
let checked = SELECTORS.getBenefitInput(conflictId).prop("checked");
if (checked){
conflictCheckbox.trigger("click");
conflictCheckbox.parent().removeClass("active");
}
});
img.setAttribute('src', initImg);
});
$(".selected").removeClass("selected");

// clear hidden form inputs
SELECTORS.getSelectedBenefits().each(function(){
$(this).prop('checked', false);
});

// update package benefits display
$(`.package-${package}-benefit`).addClass("selected");
$(`.package-${package}-benefit input`).prop("disabled", false);

// populate hidden inputs according to package's benefits
SELECTORS.getPackageBenefits(package).each(function(){
let benefit = $(this).html();
let benefitInput = SELECTORS.getBenefitInput(benefit);
benefitInput.prop("checked", true);
});

$(document).tooltip({
show: { effect: "blind", duration: 0 },
hide: false
mobileUpdate(package);
});
});


function mobileUpdate(packageId) {
// Mobile version lists a single column to controle the selected
// benefits and potential add-ons. So, this part of the code
// controls this logic.
const mobileVersion = $(".benefit-within-package:hidden").length > 0;
if (!mobileVersion) return;
$(".benefit-within-package").hide(); // hide all ticks and potential add-ons inputs
$(`div[data-package-reference=${packageId}]`).show() // display only package's ones
}


// For an unknown reason I couldn't make this logic work with jQuery.
// To don't block the development process, I pulled it off using the classic
// onclick attribute. Refactorings are welcome =]
function benefitUpdate(benefitId, packageId) {
// Change tick image for the benefit. Can't directly change the url for the image
// due to our current static files storage.
const clickedImg = document.getElementById(`benefit-${ benefitId }-package-${ packageId }`);

// Img container must have "selected" to class to be editable
if (!clickedImg.parentElement.classList.contains('selected')) return;

const newSrc = clickedImg.getAttribute("data-next-state");
clickedImg.setAttribute("data-next-state", clickedImg.src);

// Update benefit's hidden input (can't rely on click event though)
const benefitsInputs = Array(...document.querySelectorAll('[data-benefit-id]'));
const hiddenInput = benefitsInputs.filter((b) => b.getAttribute('data-benefit-id') == benefitId)[0];
hiddenInput.checked = !hiddenInput.checked;
clickedImg.src = newSrc;
};


function updatePackageInput(packageId){
const packageInput = document.getElementById(`id_package_${ packageId }`);
packageInput.click();
}

0 comments on commit 159f0bb

Please sign in to comment.