[Feature Branch] New newsletter signup module work (#11357)

* New newsletter signup module work (part 1) (#11353)

* new newsletter signup module work (part 1: creating "atom" components, "molecule" components, and "blog-body-signup.jsx")

* Create a HOC component to handle form validation logic (#11373)

* Created a HOC component to handle form validation logic

* [Blog] Add newsletter signup snippet block (#11421)

* Add BlogSignup model

* BlogSignupFactory

* Add SnippetChooserBlockFactory

* Newsletter signup block factories

* Add newsletter signup block to blog page

* Streamfield provider for blog newsletter block

* Test newsletter signup blocks

* Add newsletter block to blog page

* Fix linting

* Adjust blog signup rich text description

* Newsletter submission logic (#11398)

* added form submission logic to HOC

* added inline note for error handling todo

* code cleanup

* connect frontend with backend

* add GA/GTM tracking

* Playwright tests for the new newsletter signup module (#11476)

* Add Playwright tests for the blog body newsletter module

* Set NETWORK_SITE_URL in front end test configs to localhost and BASKET_URL to basket-dev & revert some changes

* replaced signup button text

* Update heading.jsx so the default heading is h3 with h3 styling

---------

Co-authored-by: Jhonatan Lopes <jhonatan.dapontelopes@gmail.com>
This commit is contained in:
Mavis Ou 2023-11-29 10:51:22 -08:00 коммит произвёл GitHub
Родитель 5812f67517
Коммит ca6e637c02
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
34 изменённых файлов: 2179 добавлений и 10 удалений

5
.github/workflows/continous-integration.yml поставляемый
Просмотреть файл

@ -54,7 +54,7 @@ jobs:
DJANGO_SECRET_KEY: secret
DOMAIN_REDIRECT_MIDDLEWARE_ENABLED: False
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NETWORK_SITE_URL: https://foundation.mozilla.org
NETWORK_SITE_URL: http://localhost:8000
PIPENV_VERBOSITY: -1
PULSE_API_DOMAIN: https://network-pulse-api-production.herokuapp.com
PULSE_DOMAIN: https://www.mozillapulse.org
@ -127,6 +127,7 @@ jobs:
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
env:
ALLOWED_HOSTS: 127.0.0.1,localhost,mozfest.localhost,default-site.com,secondary-site.com
BASKET_URL: https://basket-dev.allizom.org
CONTENT_TYPE_NO_SNIFF: True
CORS_ALLOWED_ORIGINS: "*"
DATABASE_URL: postgres://postgres:postgres@localhost:5432/network
@ -134,7 +135,7 @@ jobs:
DJANGO_SECRET_KEY: secret
DOMAIN_REDIRECT_MIDDLEWARE_ENABLED: False
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NETWORK_SITE_URL: https://foundation.mozilla.org
NETWORK_SITE_URL: http://localhost:8000
PIPENV_VERBOSITY: -1
PULSE_API_DOMAIN: https://network-pulse-api-production.herokuapp.com
PULSE_DOMAIN: https://www.mozillapulse.org

Просмотреть файл

@ -7,7 +7,7 @@ on:
types: [submitted]
push:
branches:
- 'main'
- "main"
jobs:
visual_regression_tests:
name: Percy CI
@ -32,7 +32,7 @@ jobs:
DJANGO_SECRET_KEY: secret
DOMAIN_REDIRECT_MIDDLEWARE_ENABLED: False
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NETWORK_SITE_URL: https://foundation.mozilla.org
NETWORK_SITE_URL: http://localhost:8000
PIPENV_VERBOSITY: -1
PULSE_API_DOMAIN: https://network-pulse-api-production.herokuapp.com
PULSE_DOMAIN: https://www.mozillapulse.org

Просмотреть файл

@ -450,6 +450,18 @@ def generate_blog_index_callout_box_field():
)
def generate_blog_newsletter_signup_field():
from networkapi.wagtailpages.factory.customblocks.newsletter_signup_block import (
BlogNewsletterSignupBlockFactory,
)
from networkapi.wagtailpages.pagemodels.customblocks.newsletter_signup_block import (
BlogNewsletterSignupBlock,
)
block = BlogNewsletterSignupBlockFactory.create()
return generate_field("newsletter_signup", BlogNewsletterSignupBlock().get_api_representation(block))
class StreamfieldProvider(BaseProvider):
"""
A custom Faker Provider for relative image urls, for use with factory_boy
@ -498,6 +510,7 @@ class StreamfieldProvider(BaseProvider):
"banner_video": generate_banner_video_field,
"current_events_slider": generate_current_events_slider_field,
"callout_box": generate_blog_index_callout_box_field,
"blog_newsletter_signup": generate_blog_newsletter_signup_field,
}
streamfield_data = []

Просмотреть файл

Просмотреть файл

@ -0,0 +1,27 @@
from wagtail_factories.blocks import ChooserBlockFactory
class SnippetChooserBlockFactory(ChooserBlockFactory):
"""Abstract factory for snippet chooser blocks.
Concrete implementations should define the `model` Meta attribute and a `snippet` field
with a subfactory pointing to the snippet factory:
```
class SignupChooserBlockFactory(SnippetChooserBlockFactory):
snippet = factory.SubFactory("networkapi.wagtailpages.factory.Signup")
class Meta:
model = Signup
```
"""
class Meta:
abstract = True
@classmethod
def _build(cls, model_class, snippet):
return snippet
@classmethod
def _create(cls, model_class, snippet):
return snippet

Просмотреть файл

@ -23,6 +23,8 @@ from .tagging import add_tags
RANDOM_SEED = settings.RANDOM_SEED
TESTING = settings.TESTING
blog_body_streamfield_fields = [
"paragraph",
"blog_newsletter_signup",
"paragraph",
"image",
"image_text",

Просмотреть файл

@ -4,3 +4,9 @@ from .blog_cta_card_with_text_block import BlogCTACardWithTextBlockFactory
from .cta_aside_block import CTAAsideBlockFactory
from .image_block import ImageBlockFactory
from .link_button_block import LinkButtonBlockFactory
from .newsletter_signup_block import (
BlogNewsletterSignupBlockFactory,
BlogSignupChooserBlockFactory,
NewsletterSignupBlockFactory,
SignupChooserBlockFactory,
)

Просмотреть файл

@ -0,0 +1,36 @@
import factory
import wagtail_factories
from networkapi.wagtailcustomization.factories.snippets import (
SnippetChooserBlockFactory,
)
from networkapi.wagtailpages.models import BlogSignup, Signup
from networkapi.wagtailpages.pagemodels import customblocks
class SignupChooserBlockFactory(SnippetChooserBlockFactory):
snippet = factory.SubFactory("networkapi.wagtailpages.factory.signup.SignupFactory")
class Meta:
model = Signup
class NewsletterSignupBlockFactory(wagtail_factories.StructBlockFactory):
class Meta:
model = customblocks.NewsletterSignupBlock
signup = factory.SubFactory(SignupChooserBlockFactory)
class BlogSignupChooserBlockFactory(SnippetChooserBlockFactory):
snippet = factory.SubFactory("networkapi.wagtailpages.factory.signup.BlogSignupFactory")
class Meta:
model = BlogSignup
class BlogNewsletterSignupBlockFactory(wagtail_factories.StructBlockFactory):
class Meta:
model = customblocks.BlogNewsletterSignupBlock
signup = factory.SubFactory(BlogSignupChooserBlockFactory)

Просмотреть файл

@ -1,4 +1,4 @@
from networkapi.wagtailpages.models import Signup
from networkapi.wagtailpages.models import BlogSignup, Signup
from .abstract import CTAFactory
@ -6,3 +6,8 @@ from .abstract import CTAFactory
class SignupFactory(CTAFactory):
class Meta:
model = Signup
class BlogSignupFactory(CTAFactory):
class Meta:
model = BlogSignup

Просмотреть файл

@ -0,0 +1,991 @@
# Generated by Django 3.2.21 on 2023-11-14 15:18
import uuid
import django.db.models.deletion
import wagtail.blocks
import wagtail.blocks.static_block
import wagtail.documents.blocks
import wagtail.embeds.blocks
import wagtail.fields
import wagtail.images.blocks
import wagtail.snippets.blocks
import wagtailmedia.blocks
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("wagtailcore", "0078_referenceindex"),
("wagtailpages", "0113_alter_blogpage_body"),
]
operations = [
migrations.AlterField(
model_name="blogpage",
name="body",
field=wagtail.fields.StreamField(
[
(
"accordion",
wagtail.blocks.StructBlock(
[
(
"accordion_items",
wagtail.blocks.ListBlock(
wagtail.blocks.StructBlock(
[
(
"title",
wagtail.blocks.CharBlock(
help_text="Heading for the Accordion Item"
),
),
(
"content",
wagtail.blocks.StreamBlock(
[
(
"rich_text",
wagtail.blocks.RichTextBlock(
blank=True,
features=[
"bold",
"italic",
"link",
"ul",
"ol",
"document-link",
],
),
),
(
"datawrapper",
wagtail.embeds.blocks.EmbedBlock(
help_text='Enter the "visualization only" link of the Datawrapper chart. It looks something like this: https://datawrapper.dwcdn.net/KwSKp/1/',
icon="image",
template="wagtailpages/blocks/datawrapper_block.html",
),
),
(
"image",
wagtail.blocks.StructBlock(
[
(
"image",
wagtail.images.blocks.ImageChooserBlock(),
),
(
"altText",
wagtail.blocks.CharBlock(
help_text="Image description (for screen readers).",
required=True,
),
),
]
),
),
(
"video",
wagtail.blocks.StructBlock(
[
(
"url",
wagtail.blocks.CharBlock(
help_text="For YouTube: go to your YouTube video and click “Share,” then “Embed,” and then copy and paste the provided URL only. For example: https://www.youtube.com/embed/3FIVXBawyQw For Vimeo: follow similar steps to grab the embed URL. For example: https://player.vimeo.com/video/9004979",
label="Embed URL",
),
),
(
"caption",
wagtail.blocks.CharBlock(required=False),
),
(
"captionURL",
wagtail.blocks.CharBlock(
help_text="Optional URL for caption to link to.",
required=False,
),
),
(
"video_width",
wagtail.blocks.ChoiceBlock(
choices=[
("normal", "Normal"),
("wide", "Wide"),
("full_width", "Full Width"),
],
help_text="Wide videos are col-12, Full-Width videos reach both ends of the screen.",
),
),
]
),
),
]
),
),
]
)
),
)
]
),
),
(
"paragraph",
wagtail.blocks.RichTextBlock(
features=[
"bold",
"italic",
"link",
"h2",
"h3",
"h4",
"h5",
"ol",
"ul",
"hr",
"document-link",
],
template="wagtailpages/blocks/rich_text_block.html",
),
),
(
"card_grid",
wagtail.blocks.StructBlock(
[
(
"cards",
wagtail.blocks.ListBlock(
wagtail.blocks.StructBlock(
[
("image", wagtail.images.blocks.ImageChooserBlock()),
(
"alt_text",
wagtail.blocks.CharBlock(
help_text="Alt text for card's image.", required=False
),
),
("title", wagtail.blocks.CharBlock(help_text="Heading for the card.")),
("body", wagtail.blocks.TextBlock(help_text="Body text of the card.")),
(
"link_url",
wagtail.blocks.CharBlock(
help_text="Optional URL that this card should link out to. (Note: If left blank, link will not render.) ",
required=False,
),
),
(
"link_label",
wagtail.blocks.CharBlock(
help_text="Optional Label for the URL link above. (Note: If left blank, link will not render.) ",
required=False,
),
),
]
),
help_text="Please use a minimum of 2 cards.",
),
)
]
),
),
(
"CTA_card",
wagtail.blocks.StructBlock(
[
(
"style",
wagtail.blocks.ChoiceBlock(
choices=[("pop", "Pop"), ("outline", "Outline"), ("filled", "Filled")]
),
),
(
"title",
wagtail.blocks.CharBlock(
help_text="Optional title for the card.", max_length=100, required=False
),
),
(
"body",
wagtail.blocks.RichTextBlock(
features=["bold", "italic", "link", "hr", "h4", "h5", "ul", "ol"],
help_text="Body text of the card.",
),
),
(
"image",
wagtail.blocks.ListBlock(
wagtail.blocks.StructBlock(
[
("image", wagtail.images.blocks.ImageChooserBlock()),
(
"altText",
wagtail.blocks.CharBlock(
help_text="Image description (for screen readers).",
required=True,
),
),
]
),
default=[],
max_num=1,
min_num=0,
),
),
(
"button",
wagtail.blocks.ListBlock(
wagtail.blocks.StructBlock(
[
("label", wagtail.blocks.CharBlock()),
("URL", wagtail.blocks.CharBlock()),
(
"styling",
wagtail.blocks.ChoiceBlock(
choices=[
("btn-primary", "Primary button"),
("btn-secondary", "Secondary button"),
]
),
),
]
),
default=[],
max_num=1,
min_num=0,
),
),
]
),
),
(
"CTA_card_with_text",
wagtail.blocks.StructBlock(
[
(
"alignment",
wagtail.blocks.ChoiceBlock(
choices=[("right", "Right"), ("left", "Left")],
help_text="For full-width cards, please use a regular Blog CTA Card block with a separate paragraph.",
),
),
(
"card",
wagtail.blocks.StructBlock(
[
(
"style",
wagtail.blocks.ChoiceBlock(
choices=[
("pop", "Pop"),
("outline", "Outline"),
("filled", "Filled"),
]
),
),
(
"title",
wagtail.blocks.CharBlock(
help_text="Optional title for the card.",
max_length=100,
required=False,
),
),
(
"body",
wagtail.blocks.RichTextBlock(
features=["bold", "italic", "link", "hr", "h4", "h5", "ul", "ol"],
help_text="Body text of the card.",
),
),
(
"image",
wagtail.blocks.ListBlock(
wagtail.blocks.StructBlock(
[
("image", wagtail.images.blocks.ImageChooserBlock()),
(
"altText",
wagtail.blocks.CharBlock(
help_text="Image description (for screen readers).",
required=True,
),
),
]
),
default=[],
max_num=1,
min_num=0,
),
),
(
"button",
wagtail.blocks.ListBlock(
wagtail.blocks.StructBlock(
[
("label", wagtail.blocks.CharBlock()),
("URL", wagtail.blocks.CharBlock()),
(
"styling",
wagtail.blocks.ChoiceBlock(
choices=[
("btn-primary", "Primary button"),
("btn-secondary", "Secondary button"),
]
),
),
]
),
default=[],
max_num=1,
min_num=0,
),
),
],
required=True,
template="wagtailpages/blocks/blog_cta_card_block_regular.html",
),
),
(
"paragraph",
wagtail.blocks.RichTextBlock(
features=[
"bold",
"italic",
"link",
"h2",
"h3",
"h4",
"h5",
"ol",
"ul",
"hr",
"document-link",
],
help_text="Text to be displayed next to the card.",
template="wagtailpages/blocks/rich_text_block.html",
),
),
]
),
),
(
"image_grid",
wagtail.blocks.StructBlock(
[
(
"grid_items",
wagtail.blocks.ListBlock(
wagtail.blocks.StructBlock(
[
("image", wagtail.images.blocks.ImageChooserBlock()),
(
"alt_text",
wagtail.blocks.CharBlock(
help_text="Alt text for this image.", required=False
),
),
(
"caption",
wagtail.blocks.CharBlock(
help_text="Please remember to properly attribute any images we use.",
required=False,
),
),
(
"url",
wagtail.blocks.CharBlock(
help_text="Optional URL that this figure should link out to.",
required=False,
),
),
(
"square_image",
wagtail.blocks.BooleanBlock(
default=True,
help_text="If left checked, the image will be cropped to be square.",
required=False,
),
),
]
)
),
)
]
),
),
(
"iframe",
wagtail.blocks.StructBlock(
[
(
"url",
wagtail.blocks.CharBlock(
help_text="Please note that only URLs from allow-listed domains will work."
),
),
(
"height",
wagtail.blocks.IntegerBlock(
help_text="Optional integer pixel value for custom iFrame height",
required=False,
),
),
("caption", wagtail.blocks.CharBlock(required=False)),
(
"captionURL",
wagtail.blocks.CharBlock(
help_text="Optional URL that this caption should link out to.", required=False
),
),
(
"iframe_width",
wagtail.blocks.ChoiceBlock(
choices=[("normal", "Normal"), ("wide", "Wide"), ("full_width", "Full Width")],
help_text="Wide iframes are col-12, Full-Width iframes reach both ends of the screen",
),
),
(
"disable_scroll",
wagtail.blocks.BooleanBlock(
default=False,
help_text='Checking this will add "scrolling=no" to the iframe. Use this if your iframe is rendering an unnecessary scroll bar or whitespace below it.',
required=False,
),
),
]
),
),
(
"image",
wagtail.blocks.StructBlock(
[
("image", wagtail.images.blocks.ImageChooserBlock()),
(
"altText",
wagtail.blocks.CharBlock(
help_text="Image description (for screen readers).", required=True
),
),
("caption", wagtail.blocks.CharBlock(required=False)),
(
"captionURL",
wagtail.blocks.CharBlock(
help_text="Optional URL that this caption should link out to.", required=False
),
),
(
"image_width",
wagtail.blocks.ChoiceBlock(
choices=[("normal", "Normal"), ("wide", "Wide"), ("full_width", "Full Width")],
help_text="Wide images are col-12, Full-Width Images reach both ends of the screen (16:6 images recommended for full width)",
),
),
]
),
),
(
"audio",
wagtail.blocks.StructBlock(
[
("audio", wagtailmedia.blocks.AudioChooserBlock()),
("caption", wagtail.blocks.CharBlock(required=False)),
]
),
),
(
"image_text",
wagtail.blocks.StructBlock(
[
("image", wagtail.images.blocks.ImageChooserBlock()),
(
"altText",
wagtail.blocks.CharBlock(
help_text="Image description (for screen readers).", required=True
),
),
(
"text",
wagtail.blocks.RichTextBlock(
features=[
"bold",
"italic",
"link",
"h2",
"h3",
"h4",
"h5",
"ol",
"ul",
"hr",
"document-link",
]
),
),
(
"url",
wagtail.blocks.CharBlock(
help_text="Optional URL that this image should link out to.", required=False
),
),
(
"top_divider",
wagtail.blocks.BooleanBlock(
help_text="Optional divider above content block.", required=False
),
),
(
"bottom_divider",
wagtail.blocks.BooleanBlock(
help_text="Optional divider below content block.", required=False
),
),
]
),
),
(
"image_text_mini",
wagtail.blocks.StructBlock(
[
("image", wagtail.images.blocks.ImageChooserBlock()),
(
"altText",
wagtail.blocks.CharBlock(
help_text="Image description (for screen readers).", required=True
),
),
("text", wagtail.blocks.RichTextBlock(features=["bold", "italic", "link"])),
]
),
),
(
"video",
wagtail.blocks.StructBlock(
[
(
"url",
wagtail.blocks.CharBlock(
help_text="For YouTube: go to your YouTube video and click “Share,” then “Embed,” and then copy and paste the provided URL only. For example: https://www.youtube.com/embed/3FIVXBawyQw For Vimeo: follow similar steps to grab the embed URL. For example: https://player.vimeo.com/video/9004979",
label="Embed URL",
),
),
("caption", wagtail.blocks.CharBlock(required=False)),
(
"captionURL",
wagtail.blocks.CharBlock(
help_text="Optional URL for caption to link to.", required=False
),
),
(
"video_width",
wagtail.blocks.ChoiceBlock(
choices=[("normal", "Normal"), ("wide", "Wide"), ("full_width", "Full Width")],
help_text="Wide videos are col-12, Full-Width videos reach both ends of the screen.",
),
),
]
),
),
(
"linkbutton",
wagtail.blocks.StructBlock(
[
("label", wagtail.blocks.CharBlock()),
("URL", wagtail.blocks.CharBlock()),
(
"styling",
wagtail.blocks.ChoiceBlock(
choices=[
("btn-primary", "Primary button"),
("btn-secondary", "Secondary button"),
]
),
),
]
),
),
(
"looping_video",
wagtail.blocks.StructBlock(
[
(
"video_url",
wagtail.blocks.CharBlock(
help_text='Log into Vimeo using 1Password and upload the desired video. Then select the video and click "Advanced", "Distribution", and "Video File Links". Copy and paste the link here.',
label="Embed URL",
),
)
]
),
),
(
"pulse_listing",
wagtail.blocks.StructBlock(
[
(
"search_terms",
wagtail.blocks.CharBlock(
help_text="Test your search at mozillapulse.org/search",
label="Search",
required=False,
),
),
(
"max_number_of_results",
wagtail.blocks.IntegerBlock(
default=6,
help_text="Choose 1-12. If you want visitors to see more, link to a search or tag on Pulse.",
max_value=12,
min_value=0,
required=True,
),
),
(
"only_featured_entries",
wagtail.blocks.BooleanBlock(
default=False,
help_text="Featured items are selected by Pulse moderators.",
label="Display only featured entries",
required=False,
),
),
(
"newest_first",
wagtail.blocks.ChoiceBlock(
choices=[
("True", "Show newer entries first"),
("False", "Show older entries first"),
],
label="Sort",
),
),
(
"advanced_filter_header",
wagtail.blocks.static_block.StaticBlock(
admin_text="-------- ADVANCED FILTERS: OPTIONS TO DISPLAY FEWER, MORE TARGETED RESULTS. --------",
label=" ",
),
),
(
"issues",
wagtail.blocks.ChoiceBlock(
choices=[
("all", "All"),
("Decentralization", "Decentralization"),
("Digital Inclusion", "Digital Inclusion"),
("Online Privacy & Security", "Online Privacy & Security"),
("Open Innovation", "Open Innovation"),
("Web Literacy", "Web Literacy"),
]
),
),
(
"help",
wagtail.blocks.ChoiceBlock(
choices=[
("all", "All"),
("Attend", "Attend"),
("Create content", "Create content"),
("Code", "Code"),
("Design", "Design"),
("Fundraise", "Fundraise"),
("Join community", "Join community"),
("Localize & translate", "Localize & translate"),
("Mentor", "Mentor"),
("Plan & organize", "Plan & organize"),
("Promote", "Promote"),
("Take action", "Take action"),
("Test & feedback", "Test & feedback"),
("Write documentation", "Write documentation"),
],
label="Type of help needed",
),
),
(
"direct_link",
wagtail.blocks.BooleanBlock(
default=False,
help_text="Checked: user goes to project link. Unchecked: user goes to pulse entry",
label="Direct link",
required=False,
),
),
]
),
),
(
"single_quote",
wagtail.blocks.StructBlock(
[
("quote", wagtail.blocks.RichTextBlock(features=["bold"])),
("attribution", wagtail.blocks.CharBlock(required=False)),
(
"attribution_info",
wagtail.blocks.RichTextBlock(features=["bold", "link", "large"], required=False),
),
]
),
),
(
"slider",
wagtail.blocks.StructBlock(
[
(
"title",
wagtail.blocks.CharBlock(help_text="Heading for the slider.", required=False),
),
(
"slides",
wagtail.blocks.StreamBlock(
[
(
"slide",
wagtail.blocks.StructBlock(
[
(
"title",
wagtail.blocks.CharBlock(
help_text="Heading of the card.", required=False
),
),
(
"image",
wagtail.images.blocks.ImageChooserBlock(
help_text="The image associated with this event."
),
),
(
"caption",
wagtail.blocks.TextBlock(
help_text="Caption for slider image", required=False
),
),
(
"body",
wagtail.blocks.RichTextBlock(
blank=True,
features=["bold", "italic", "link", "large"],
required=False,
),
),
(
"buttons",
wagtail.blocks.StreamBlock(
[
(
"internal",
wagtail.blocks.StructBlock(
[
(
"label",
wagtail.blocks.CharBlock(
help_text="Label for this link."
),
),
(
"link",
wagtail.blocks.PageChooserBlock(
help_text="Page that this should link out to."
),
),
]
),
),
(
"external",
wagtail.blocks.StructBlock(
[
(
"label",
wagtail.blocks.CharBlock(
help_text="Label for this link."
),
),
(
"link",
wagtail.blocks.URLBlock(
help_text="URL that this should link out to."
),
),
]
),
),
(
"document",
wagtail.blocks.StructBlock(
[
(
"label",
wagtail.blocks.CharBlock(
help_text="Label for this link."
),
),
(
"document",
wagtail.documents.blocks.DocumentChooserBlock(
help_text="Document that this should link out to."
),
),
],
help_text='An iCal document can be attached here for an "Add to Calendar" button.',
),
),
],
help_text="A list of buttons that will appear at the bottom of the card.",
max_num=2,
required=False,
),
),
]
),
)
],
help_text="A list of slides.",
),
),
]
),
),
(
"spacer",
wagtail.blocks.StructBlock(
[
(
"size",
wagtail.blocks.ChoiceBlock(
choices=[
("1", "quarter spacing"),
("2", "half spacing"),
("3", "single spacing"),
("4", "one and a half spacing"),
("5", "triple spacing"),
]
),
)
]
),
),
(
"airtable",
wagtail.blocks.StructBlock(
[
(
"url",
wagtail.blocks.URLBlock(
help_text="Copied from the Airtable embed code. The word 'embed' will be in the url"
),
),
(
"height",
wagtail.blocks.IntegerBlock(
default=533,
help_text="The pixel height on desktop view, usually copied from the Airtable embed code",
),
),
]
),
),
(
"datawrapper",
wagtail.embeds.blocks.EmbedBlock(
help_text='Enter the "visualization only" link of the Datawrapper chart. It looks something like this: https://datawrapper.dwcdn.net/KwSKp/1/',
icon="image",
template="wagtailpages/blocks/datawrapper_block.html",
),
),
(
"typeform",
wagtail.blocks.StructBlock(
[
(
"embed_id",
wagtail.blocks.CharBlock(
help_text="The embed id of your Typeform page (e.g. if the form is on admin.typeform.com/form/e8zScc6t, the id will be: e8zScc6t)",
required=True,
),
),
(
"button_type",
wagtail.blocks.ChoiceBlock(
choices=[
("btn-primary", "Primary button"),
("btn-secondary", "Secondary button"),
]
),
),
(
"button_text",
wagtail.blocks.CharBlock(
help_text="This is a text prompt for users to open the typeform content",
required=True,
),
),
]
),
),
(
"newsletter_signup",
wagtail.blocks.StructBlock(
[("signup", wagtail.snippets.blocks.SnippetChooserBlock("wagtailpages.BlogSignup"))]
),
),
],
use_json_field=True,
),
),
migrations.AlterField(
model_name="cta",
name="newsletter",
field=models.CharField(
default="mozilla-foundation", help_text="The (pre-existing) newsletter to sign up for", max_length=100
),
),
migrations.CreateModel(
name="BlogSignup",
fields=[
("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("translation_key", models.UUIDField(default=uuid.uuid4, editable=False)),
(
"name",
models.CharField(
default="", help_text="Identify this component for other editors", max_length=100
),
),
(
"header",
models.CharField(
blank=True, help_text="Heading that will display on page for this component", max_length=500
),
),
(
"newsletter",
models.CharField(
default="mozilla-foundation",
help_text="The (pre-existing) newsletter to sign up for",
max_length=100,
),
),
(
"description",
wagtail.fields.RichTextField(blank=True, help_text="Signup's body (richtext)", max_length=300),
),
(
"locale",
models.ForeignKey(
editable=False,
on_delete=django.db.models.deletion.PROTECT,
related_name="+",
to="wagtailcore.locale",
),
),
],
options={
"verbose_name": "Blog Signup",
"verbose_name_plural": "Blog Signups",
"ordering": ["name"],
"abstract": False,
"unique_together": {("translation_key", "locale")},
},
),
]

Просмотреть файл

@ -58,6 +58,7 @@ from .pagemodels.campaign_index import CampaignIndexPage
from .pagemodels.campaigns import (
CTA,
BanneredCampaignPage,
BlogSignup,
CampaignPage,
OpportunityPage,
Petition,

Просмотреть файл

@ -58,6 +58,7 @@ base_fields = [
("airtable", customblocks.AirTableBlock()),
("datawrapper", customblocks.DatawrapperBlock()),
("typeform", customblocks.TypeformBlock()),
("newsletter_signup", customblocks.BlogNewsletterSignupBlock()),
]
@ -112,7 +113,11 @@ class BlogPage(BasePage):
# Custom base form for additional validation
base_form_class = BlogPageForm
body = StreamField(base_fields, block_counts={"typeform": {"max_num": 1}}, use_json_field=True)
body = StreamField(
base_fields,
block_counts={"typeform": {"max_num": 1}, "newsletter_signup": {"max_num": 1}},
use_json_field=True,
)
topics = ParentalManyToManyField(
BlogPageTopic,

Просмотреть файл

@ -16,8 +16,7 @@ from .mixin.foundation_metadata import FoundationMetadataPageMixin
from .modular import MiniSiteNameSpace
@register_snippet
class CTA(models.Model):
class CTABase(models.Model):
name = models.CharField(
default="",
max_length=100,
@ -34,7 +33,7 @@ class CTA(models.Model):
newsletter = models.CharField(
max_length=100,
help_text="The (pre-existing) SalesForce newsletter to sign up for",
help_text="The (pre-existing) newsletter to sign up for",
default="mozilla-foundation",
)
@ -48,6 +47,12 @@ class CTA(models.Model):
def __str__(self):
return self.name
class Meta:
abstract = True
@register_snippet
class CTA(CTABase):
class Meta:
ordering = ["-id"]
verbose_name_plural = "CTA"
@ -135,6 +140,18 @@ class Signup(TranslatableMixin, CTA):
verbose_name = "Signup"
@register_snippet
class BlogSignup(TranslatableMixin, CTABase):
description = RichTextField(
help_text="Signup's body (richtext)", features=["bold", "italic"], max_length=300, blank=True
)
class Meta(TranslatableMixin.Meta):
ordering = ["name"]
verbose_name = "Blog Signup"
verbose_name_plural = "Blog Signups"
class OpportunityPage(MiniSiteNameSpace):
content_panels = Page.content_panels + [
FieldPanel("header"),

Просмотреть файл

@ -39,7 +39,7 @@ from .latest_profile_list import LatestProfileList
from .link_button_block import LinkButtonBlock
from .listing import ListingBlock
from .looping_video_block import LoopingVideoBlock
from .newsletter_signup_block import NewsletterSignupBlock
from .newsletter_signup_block import BlogNewsletterSignupBlock, NewsletterSignupBlock
from .profile import ProfileBlock
from .profile_by_id import ProfileById
from .profile_directory import ProfileDirectory, TabbedProfileDirectory

Просмотреть файл

@ -8,3 +8,11 @@ class NewsletterSignupBlock(blocks.StructBlock):
class Meta:
icon = "placeholder"
template = "wagtailpages/blocks/newsletter_signup_block.html"
class BlogNewsletterSignupBlock(blocks.StructBlock):
signup = SnippetChooserBlock("wagtailpages.BlogSignup")
class Meta:
icon = "placeholder"
template = "wagtailpages/blocks/blog_newsletter_signup_block.html"

Просмотреть файл

@ -0,0 +1,15 @@
{% extends "./base_streamfield_block.html" %}
{% load wagtailcore_tags wagtailimages_tags static i18n l10n %}
{% block block_container_classes %}tw-my-18{% endblock block_container_classes %}
{% block block_content %}
<div class="newsletter-signup-module"
data-module-type="blog-body"
data-form-position="blog-body"
data-signup-id="{{ self.signup.id | unlocalize }}"
data-cta-header="{{ self.signup.header | escape }}"
data-cta-description="{{ self.signup.description | escape }}"
data-newsletter="{{ self.signup.newsletter }}"
></div>
{% endblock block_content %}

Просмотреть файл

@ -0,0 +1,22 @@
from django.test import TestCase
from networkapi.wagtailpages.factory import customblocks as customblock_factories
from networkapi.wagtailpages.models import BlogSignup, Signup
class TestNewsletterSignupBlock(TestCase):
def test_newsletter_signup_block(self):
Signup.objects.all().delete()
self.assertEqual(Signup.objects.count(), 0)
signup_block = customblock_factories.NewsletterSignupBlockFactory()
self.assertTrue(isinstance(signup_block["signup"], Signup))
self.assertEqual(Signup.objects.count(), 1)
class TestBlogNewsletterSignupBlock(TestCase):
def test_blog_newsletter_signup_block(self):
BlogSignup.objects.all().delete()
self.assertEqual(BlogSignup.objects.count(), 0)
signup_block = customblock_factories.BlogNewsletterSignupBlockFactory()
self.assertTrue(isinstance(signup_block["signup"], BlogSignup))
self.assertEqual(BlogSignup.objects.count(), 1)

Просмотреть файл

@ -0,0 +1 @@
<svg fill="none" height="19" viewBox="0 0 26 19" width="26" xmlns="http://www.w3.org/2000/svg"><path d="m2.18855 2.09091 9.23875 9.78159c.8587.9092 2.3044.9119 3.1666.006l9.3152-9.78759m-21.81819 15.27269 6.54545-6.5454m15.27274 6.5454-6.5455-6.5454m-14.18178 7.6363h19.63638c1.205 0 2.1818-.9768 2.1818-2.1818v-13.09088c0-1.20499-.9768-2.18182-2.1818-2.18182h-19.63638c-1.20499 0-2.18182.97683-2.18182 2.18182v13.09088c0 1.205.97683 2.1818 2.18182 2.1818z" stroke="#333" stroke-linecap="round" stroke-linejoin="round"/></svg>

После

Ширина:  |  Высота:  |  Размер: 526 B

Просмотреть файл

@ -1,4 +1,5 @@
import injectJoinUs from "./join-us.js";
import injectNewsletterSignup from "./newsletter-signup-module.js";
import injectPetitionThankYou from "./petition-thank-you.js";
/**
@ -8,6 +9,7 @@ import injectPetitionThankYou from "./petition-thank-you.js";
*/
export const injectCommonReactComponents = (apps, siteUrl) => {
injectJoinUs(apps, siteUrl);
injectNewsletterSignup(apps, siteUrl);
// FormAssembly petition thank you screen
injectPetitionThankYou(apps);
};

Просмотреть файл

@ -0,0 +1,29 @@
import React from "react";
import { createRoot } from "react-dom/client";
import BlogBodySignupForm from "../../components/newsletter-signup/organisms/blog-body-signup.jsx";
/**
* Inject newsletter signup forms
* @param {Array} apps The existing array we are using to to track all React client rendering calls
* @param {String} siteUrl Foundation site base URL
*/
export default (apps, siteUrl) => {
document
.querySelectorAll(`.newsletter-signup-module[data-module-type="blog-body"]`)
.forEach((element) => {
const props = element.dataset;
const sid = props.signupId || 0;
props.apiUrl = `${siteUrl}/api/campaign/signups/${sid}/`;
props.isHidden = false;
apps.push(
new Promise((resolve) => {
const root = createRoot(element);
root.render(
<BlogBodySignupForm {...props} whenLoaded={() => resolve()} />
);
})
);
});
};

Просмотреть файл

@ -0,0 +1,24 @@
import React from "react";
import classNames from "classnames";
import PropTypes from "prop-types";
const ButtonSubmit = ({ children, widthClasses }) => {
// [TODO]
// Ideally styling for this "atom" component should be pre-defined in a Tailwind config file.
// Because our design system still needs to be finalized,
// we are using hardcoded Tailwind classes directly here for now.
let classes = classNames(`tw-btn tw-btn-primary`, widthClasses);
return (
<button type="submit" className={classes}>
{children}
</button>
);
};
ButtonSubmit.propTypes = {
children: PropTypes.node.isRequired,
widthClasses: PropTypes.string,
};
export default ButtonSubmit;

Просмотреть файл

@ -0,0 +1,22 @@
import React from "react";
import PropTypes from "prop-types";
const Description = ({ content }) => {
if (typeof content === "string") {
return (
<div
dangerouslySetInnerHTML={{
__html: content,
}}
/>
);
}
return <div>{content}</div>;
};
Description.propTypes = {
content: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired,
};
export default Description;

Просмотреть файл

@ -0,0 +1,14 @@
import React from "react";
import PropTypes from "prop-types";
const Heading = ({ level = 3, children }) => {
const TagName = `h${level}`;
return <TagName className="tw-h3-heading">{children}</TagName>;
};
Heading.propTypes = {
level: PropTypes.number,
children: PropTypes.node.isRequired,
};
export default Heading;

Просмотреть файл

@ -0,0 +1,21 @@
import React from "react";
import PropTypes from "prop-types";
const InputCheckbox = (props) => {
// [TODO]
// Ideally styling for this "atom" component should be pre-defined in a Tailwind config file.
// Because our design system still needs to be finalized,
// we are using hardcoded Tailwind classes directly here for now.
return <input type="checkbox" className="form-check-input" {...props} />;
};
InputCheckbox.propTypes = {
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
value: PropTypes.string.isRequired,
checked: PropTypes.bool,
onChange: PropTypes.func.isRequired,
required: PropTypes.bool,
};
export default InputCheckbox;

Просмотреть файл

@ -0,0 +1,82 @@
import React from "react";
import PropTypes from "prop-types";
import classNames from "classnames";
// [TODO] probably worth moving this into Tailwind config?
// [TODO]
// Ideally styling for this "atom" component should be pre-defined in a Tailwind config file.
// Because our design system still needs to be finalized,
// we are using hardcoded Tailwind classes directly here for now.
const FIELD_CLASSES = `
tw-form-control
has-error:tw-border
has-error:tw-border-solid
has-error:tw-border-[#c01]
dark:has-error:tw-border-2
dark:has-error:tw-border-red-40
tw-border-1
tw-border-black
placeholder:tw-text-gray-40
focus:tw-border-blue-40
focus:tw-shadow-none
focus-visible:tw-drop-shadow-none
tw-pr-18
`;
const InputEmail = ({
ariaLabel,
outerMarginClasses = "",
errorMessage,
...otherProps
}) => {
let inputField = (
<input
type="email"
className={FIELD_CLASSES}
{...otherProps}
{...(ariaLabel ? { "aria-label": ariaLabel } : {})}
/>
);
let errorNotice = null;
if (errorMessage) {
inputField = (
<div className="tw-relative">
{inputField}
<div className="tw-absolute tw-top-0 tw-bottom-0 tw-right-0 tw-flex tw-items-center tw-justify-end">
<span className="tw-form-error-glyph" />
</div>
</div>
);
errorNotice = (
<>
<p className="error-message tw-body-small tw-mt-4 tw-text-[#c01] dark:tw-text-red-40">
{errorMessage}
</p>
</>
);
}
return (
<div className={outerMarginClasses}>
{inputField}
{errorNotice}
</div>
);
};
InputEmail.propTypes = {
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
value: PropTypes.string.isRequired,
placeholder: PropTypes.string,
onFocus: PropTypes.func.isRequired,
onInput: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
required: PropTypes.bool,
ariaLabel: PropTypes.string,
errorMessage: PropTypes.string,
};
export default InputEmail;

Просмотреть файл

@ -0,0 +1,18 @@
import React from "react";
import PropTypes from "prop-types";
const Label = ({ htmlFor, children, classes }) => {
return (
<label htmlFor={htmlFor} className={classes}>
{children}
</label>
);
};
Label.propTypes = {
htmlFor: PropTypes.string.isRequired,
children: PropTypes.node.isRequired,
classes: PropTypes.string,
};
export default Label;

Просмотреть файл

@ -0,0 +1,43 @@
import React from "react";
import classNames from "classnames";
import PropTypes from "prop-types";
const BASE_CLASSES = `
tw-form-control
tw-w-full
tw-border-1
tw-border-black
focus:tw-border-blue-40
focus:tw-shadow-none
`;
const Select = ({ options, outerMarginClasses, ...otherProps }) => {
let classes = classNames(BASE_CLASSES, outerMarginClasses);
return (
<select {...otherProps} className={classes}>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
);
};
Select.propTypes = {
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
value: PropTypes.string.isRequired,
options: PropTypes.arrayOf(
PropTypes.shape({
value: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
})
).isRequired,
onChange: PropTypes.func.isRequired,
required: PropTypes.bool,
outerMarginClasses: PropTypes.string,
};
export default Select;

Просмотреть файл

@ -0,0 +1,7 @@
import React from "react";
const ValidationError = ({ message }) => {
return <p>{message}</p>;
};
export default ValidationError;

Просмотреть файл

@ -0,0 +1,13 @@
import SALESFORCE_COUNTRY_LIST from "../../petition/salesforce-country-list.js";
import { getText } from "../../petition/locales";
let countryDefault = { value: "", label: getText(`Your country`) };
let countryOptions = Object.keys(SALESFORCE_COUNTRY_LIST).map((code) => {
return {
value: code,
label: SALESFORCE_COUNTRY_LIST[code],
};
});
countryOptions.unshift(countryDefault);
export const COUNTRY_OPTIONS = countryOptions;

Просмотреть файл

@ -0,0 +1,17 @@
const LANGUAGES = {
en: `English`,
de: `Deutsch`,
es: `Español`,
fr: `Français`,
pl: `Polski`,
"pt-BR": `Português`,
};
let languageOptions = Object.keys(LANGUAGES).map((code) => {
return {
value: code,
label: LANGUAGES[code],
};
});
export const LANGUAGE_OPTIONS = languageOptions;

Просмотреть файл

@ -0,0 +1,41 @@
import React from "react";
import PropTypes from "prop-types";
import Label from "../atoms/label.jsx";
import InputCheckbox from "../atoms/input-checkbox.jsx";
const InputCheckboxField = ({ id, label, errorMessage, ...otherProps }) => {
return (
<div className="tw-flex tw-items-start tw-relative tw-pl-10">
<InputCheckbox id={id} {...otherProps} />
<div>
<div className="tw-flex">
<Label
htmlFor={id}
classes="tw-block form-check-label tw-body-small tw-text-black"
>
{label}
</Label>
{errorMessage && (
<span className="tw-form-error-glyph tw-flex tw-items-center tw-ml-2" />
)}
</div>
{errorMessage && (
<p className="error-message tw-body-small tw-mt-0 tw-text-[#c01] dark:tw-text-red-40">
{errorMessage}
</p>
)}
</div>
</div>
);
};
InputCheckboxField.propTypes = {
id: PropTypes.string.isRequired,
label: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired,
value: PropTypes.string.isRequired,
checked: PropTypes.bool,
onChange: PropTypes.func.isRequired,
required: PropTypes.bool,
};
export default InputCheckboxField;

Просмотреть файл

@ -0,0 +1,232 @@
import React, { Component } from "react";
import PropTypes from "prop-types";
import classNames from "classnames";
import Heading from "../atoms/heading.jsx";
import Description from "../atoms/description.jsx";
import InputEmail from "../atoms/input-email.jsx";
import Select from "../atoms/select.jsx";
import InputCheckboxWithLabel from "../molecules/input-checkbox-with-label.jsx";
import ButtonSubmit from "../atoms/button-submit.jsx";
import withSubmissionLogic from "./with-submission-logic.jsx";
import utility from "../../../utility.js";
import { ReactGA } from "../../../common";
import { getText } from "../../petition/locales";
import { getCurrentLanguage } from "../../petition/locales";
import { COUNTRY_OPTIONS } from "../data/country-options.js";
import { LANGUAGE_OPTIONS } from "../data/language-options.js";
const FIELD_MARGIN_CLASSES = `tw-mb-4`;
const FIELD_ID_PREFIX = `blog-body-newsletter`;
class BlogBodySignForm extends Component {
constructor(props) {
super(props);
this.state = this.getInitialState();
this.ids = this.generateFieldIds([
"email",
"country",
"language",
"privacy",
]);
}
getInitialState() {
return {
formData: {
email: "",
country: "",
language: getCurrentLanguage(),
privacy: "",
},
showAllFields: false,
};
}
// generate unique IDs for form fields
generateFieldIds(fieldNames = []) {
return fieldNames.reduce((obj, field) => {
obj[field] = utility.generateUniqueId(`${FIELD_ID_PREFIX}-${field}`);
return obj;
}, {});
}
showAllFields() {
ReactGA.event({
category: `signup`,
action: `form focus`,
label: `Signup form input focused`,
});
this.setState({ showAllFields: true });
}
updateFormFieldValue(name, value) {
this.setState((prevState) => ({
formData: {
...prevState.formData,
[name]: value,
},
}));
}
getFormFieldValue(name) {
return this.state.formData[name];
}
handleEmailChange(event) {
this.updateFormFieldValue("email", event.target.value);
}
handleCountryChange(event) {
this.updateFormFieldValue("country", event.target.value);
}
handleLanguageChange(event) {
this.updateFormFieldValue("language", event.target.value);
}
handlePrivacyChange(event) {
this.updateFormFieldValue("privacy", event.target.checked.toString());
}
renderHeader() {
if (!this.props.ctaHeader) return null;
return <Heading level={2}>{this.props.ctaHeader}</Heading>;
}
renderDescription() {
if (!this.props.ctaDescription) return null;
return <Description content={this.props.ctaDescription} />;
}
renderEmailField() {
const name = "email";
const outerMarginClasses = classNames({
[FIELD_MARGIN_CLASSES]: true,
"tw-has-error": !!this.props.errors[name],
});
return (
<InputEmail
id={this.ids.email}
name={name}
label={getText(`Email address`)}
value={this.getFormFieldValue(name)}
placeholder={getText(`Please enter your email`)}
onFocus={() => this.showAllFields()}
onInput={() => this.showAllFields()}
onChange={(event) => this.handleEmailChange(event)}
required={true}
outerMarginClasses={outerMarginClasses}
errorMessage={this.props.errors[name]}
/>
);
}
renderAdditionalFields() {
const nameCountry = "country";
const nameLanguage = "language";
return (
<>
<Select
id={this.ids.country}
name={nameCountry}
value={this.getFormFieldValue(nameCountry)}
options={COUNTRY_OPTIONS}
onChange={(event) => this.handleCountryChange(event)}
required={false}
outerMarginClasses={FIELD_MARGIN_CLASSES}
/>
<Select
id={this.ids.language}
name={nameLanguage}
value={this.getFormFieldValue(nameLanguage)}
options={LANGUAGE_OPTIONS}
onChange={(event) => this.handleLanguageChange(event)}
required={false}
outerMarginClasses={FIELD_MARGIN_CLASSES}
/>
</>
);
}
renderPrivacyCheckbox() {
const name = "privacy";
return (
<InputCheckboxWithLabel
id={this.ids.privacy}
name={name}
label={getText(
`I'm okay with Mozilla handling my info as explained in this Privacy Notice`
)}
value={this.getFormFieldValue(name)}
checked={this.getFormFieldValue(name) === "true"}
onChange={(event) => this.handlePrivacyChange(event)}
required={true}
errorMessage={this.props.errors[name]}
/>
);
}
renderForm() {
if (this.props.hideForm) return null;
return (
<form
noValidate={this.props.noBrowserValidation}
onSubmit={(event) => this.props.onSubmit(event, this.state.formData)}
>
<div className="d-flex flex-column flex-md-row medium:tw-gap-8">
<div className="tw-flex-grow">
<fieldset className={FIELD_MARGIN_CLASSES}>
{this.renderEmailField()}
{this.state.showAllFields && this.renderAdditionalFields()}
</fieldset>
<fieldset>{this.renderPrivacyCheckbox()}</fieldset>
</div>
<div className="tw-mt-8 medium:tw-mt-0">
<ButtonSubmit widthClasses="tw-w-full">
{getText("Sign Up")}
</ButtonSubmit>
</div>
</div>
</form>
);
}
render() {
return (
<div
className={`
${this.props.innerWrapperClass}
tw-relative tw-border tw-px-8 tw-pt-14 tw-pb-12 medium:tw-p-16
before:tw-absolute before:tw-top-0 before:tw-left-1/2 before:-tw-translate-x-1/2 before:-tw-translate-y-1/2 before:tw-content-[''] before:tw-inline-block before:tw-w-[72px] before:tw-h-14 before:tw-bg-[url('../_images/glyphs/letter.svg')] before:tw-bg-white before:tw-bg-no-repeat before:tw-bg-center before:tw-bg-[length:24px_auto]
`}
data-submission-status={this.props.apiSubmissionStatus}
>
{this.renderHeader()}
{this.renderDescription()}
{this.renderForm()}
</div>
);
}
}
BlogBodySignForm.propTypes = {
ctaHeader: PropTypes.string,
ctaDescription: PropTypes.oneOfType([PropTypes.string, PropTypes.node])
.isRequired,
errors: PropTypes.shape({
fieldName: PropTypes.string,
errorMessage: PropTypes.string,
}),
onSubmit: PropTypes.func.isRequired,
noBrowserValidation: PropTypes.bool,
hideForm: PropTypes.bool,
};
export default withSubmissionLogic(BlogBodySignForm);

Просмотреть файл

@ -0,0 +1,266 @@
import React from "react";
import PropTypes from "prop-types";
import { ReactGA } from "../../../common";
import { getText } from "../../petition/locales";
/**
* Higher-order component that handles form submission logic and validation
* for newsletter signup forms.
*/
function withSubmissionLogic(WrappedComponent) {
class WithSubmissionLogicComponent extends React.Component {
constructor(props) {
super(props);
this.API_SUBMISSION_STATUS = {
NONE: "none", // no submission has been made yet
PENDING: "pending", // a call has been initiated but response has not been received yet
SUCCESS: "success", // response has been received and status code is 201
ERROR: "error", // response has been received but status code is not 201
};
this.state = {
apiSubmissionStatus: this.API_SUBMISSION_STATUS.NONE,
errors: {},
};
this.validators = {
email: (value) => {
if (!value) {
return getText(`This is a required section.`);
}
// Regex copied from join.jsx
const emailRegex = new RegExp(/[^@]+@[^.@]+(\.[^.@]+)+$/);
if (!emailRegex.test(value)) {
return getText(`Please enter a valid email address.`);
}
return null;
},
privacy: (value) => {
if (value !== "true") {
return getText(`Please check this box if you want to proceed.`);
}
return null;
},
};
}
/**
* Validate a single field
*
* @param {string} name - name of the field
* @param {string} value - value of the field
* @returns {object} - { [name]: errorMessage }
*/
validateField(name, value) {
const validator = this.validators[name];
if (!validator) return {};
return { [name]: validator(value) };
}
/**
* Validate all fields in the form and update state with the new errors
*
* @param {object} formData - { [name]: value } pairs
* @param {function} done - callback function
* @returns {void}
*/
validateForm(formData, done) {
// validate all fields
// and combine { [name] : errorMessage } pairs into a single object
const newErrors = Object.entries(formData)
.map(([name, value]) => {
return this.validateField(name, value);
})
.reduce((acc, curr) => {
return { ...acc, ...curr };
}, {});
// update state with new errors
this.setState({ errors: newErrors }, () => {
done();
});
}
/**
* Track form submission with GA and GTM
*
* @param {object} formData - { [name]: value } pairs
* @returns {void}
*/
trackFormSubmit(formData) {
ReactGA.event({
category: `signup`,
action: `form submit tap`,
label: `Signup submitted from ${
this.props.formPosition ? this.props.formPosition : document.title
}`,
});
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
event: "form_submission",
form_type: "newsletter_signup",
form_location: this.props.formPosition || null,
country: formData.country,
language: formData.language,
});
}
/**
* Form submission handler
*
* @param {object} event - event object
* @param {object} formData - { [name]: value } pairs
* @returns {void}
*/
handleSubmit(event, formData) {
event.preventDefault();
this.trackFormSubmit(formData);
this.validateForm(formData, () => {
// Check if there's any error messages in this.state.errors object
// if there's none, we can submit the form
if (Object.values(this.state.errors).every((error) => !error)) {
this.submitDataToApi(formData)
.then(() => {
this.setState({
apiSubmissionStatus: this.API_SUBMISSION_STATUS.SUCCESS,
});
})
.catch(() => {
// [TODO][FIXME] We need to let the user know that something went wrong
// https://github.com/MozillaFoundation/foundation.mozilla.org/issues/11406
this.setState({
apiSubmissionStatus: this.API_SUBMISSION_STATUS.ERROR,
});
});
}
});
}
/**
* Submit data to API
*
* @param {*} formData - { [name]: value } pairs
* @returns {Promise} - resolves if response status is 201, rejects otherwise
*/
async submitDataToApi(formData) {
this.setState({
apiSubmissionStatus: this.API_SUBMISSION_STATUS.PENDING,
});
let payload = {
email: formData.email,
country: formData.country,
lang: formData.language,
// keeping query params in source url for newsletter signups:
// https://github.com/mozilla/foundation.mozilla.org/issues/4102#issuecomment-590973606
source: window.location.href,
};
try {
const res = await fetch(this.props.apiUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Requested-With": "XMLHttpRequest",
},
body: JSON.stringify(payload),
timeout: 5000,
});
// if the response is not 201, throw an error
// [TODO] We will need to update this logic depending on what comes out of
// https://github.com/MozillaFoundation/foundation.mozilla.org/issues/11406
if (res.status !== 201) {
throw new Error(res.statusText);
}
} catch (error) {
throw error;
}
}
/**
* Generate the CTA header message for different scenarios
*
* @param {string} ctaHeader - default CTA header message
* @returns {string} - CTA header message
*/
generateCtaHeader(ctaHeader) {
let message = ctaHeader;
if (
this.state.apiSubmissionStatus === this.API_SUBMISSION_STATUS.SUCCESS
) {
message = getText(`Thanks!`);
}
return message;
}
/**
* Generate the CTA description message for different scenarios
*
* @param {*} ctaDescription - default CTA description message
* @returns {string} - CTA description message
*/
generateCtaDescription(ctaDescription) {
let message = ctaDescription;
if (
this.state.apiSubmissionStatus === this.API_SUBMISSION_STATUS.SUCCESS
) {
message = (
<>
<p>{getText(`confirm your email opt-in`)}</p>
<p>{getText(`manage your subscriptions`)}</p>
</>
);
}
return message;
}
/**
* Render the wrapped component with additional props
*/
render() {
let { ctaHeader, ctaDescription, ...otherProps } = this.props;
return (
<WrappedComponent
{...otherProps}
innerWrapperClass="inner-wrapper"
noBrowserValidation={true}
errors={this.state.errors}
onSubmit={(event, formData) => this.handleSubmit(event, formData)}
ctaHeader={this.generateCtaHeader(ctaHeader)}
ctaDescription={this.generateCtaDescription(ctaDescription)}
hideForm={
this.state.apiSubmissionStatus ===
(this.API_SUBMISSION_STATUS.SUCCESS ||
this.API_SUBMISSION_STATUS.ERROR)
}
apiSubmissionStatus={this.state.apiSubmissionStatus}
/>
);
}
}
WithSubmissionLogicComponent.propTypes = {
apiUrl: PropTypes.string.isRequired,
ctaHeader: PropTypes.string.isRequired,
ctaDescription: PropTypes.string.isRequired,
formPosition: PropTypes.string.isRequired,
};
return WithSubmissionLogicComponent;
}
export default withSubmissionLogic;

Просмотреть файл

@ -0,0 +1,188 @@
const { test, expect } = require("@playwright/test");
const waitForImagesToLoad = require("../../wait-for-images.js");
const {
LANGUAGE_OPTIONS,
} = require("../../../source/js/components/newsletter-signup/data/language-options.js");
const locales = LANGUAGE_OPTIONS.map((language) => language.value);
function generateUrl(locale = "en") {
return `http://localhost:8000/${locale}/blog/initial-test-blog-post-with-fixed-title/?random=query`;
}
test.describe("Blog body newsletter signup form", () => {
let localeToTest = locales[0];
let pageUrl,
formEmail,
moduleContainer,
innerWrapper,
countryDropdown,
languageDropdown,
submitButton,
errorMessages;
test.beforeEach(async ({ page }) => {
pageUrl = generateUrl(localeToTest);
formEmail = `test-${localeToTest}-${Date.now()}@example.com`; // adding a timestamp to make the email unique
const response = await page.goto(pageUrl);
const status = await response.status();
expect(status).not.toBe(404);
await page.locator("body.react-loaded");
await waitForImagesToLoad(page);
// test if the newsletter module is visible
moduleContainer = page.locator(
".newsletter-signup-module[data-module-type='blog-body']"
);
await moduleContainer.waitFor({ state: "visible" });
expect(await moduleContainer.count()).toBe(1);
// test if the inner wrapper is visible and data-submission-status attribute is set to "none"
innerWrapper = moduleContainer.locator(".inner-wrapper");
await innerWrapper.waitFor({ state: "visible" });
expect(await innerWrapper.count()).toBe(1);
expect(await innerWrapper.getAttribute("data-submission-status")).toBe(
"none"
);
// test if the form inside the newsletter module is visible
const form = moduleContainer.locator("form");
await form.waitFor({ state: "visible" });
expect(await form.count()).toBe(1);
// test if there's a submit button
submitButton = form.locator(`button[type="submit"]`);
expect(await submitButton.count()).toBe(1);
// test if the required fields (email and privacy checkbox) are rendered on the page
const emailInput = form.locator(`input[type="email"]`);
expect(await emailInput.count()).toBe(1);
expect(await emailInput.inputValue()).toBe("");
expect(await emailInput.getAttribute("required")).toBe("");
const privacyInput = form.locator(`input[type="checkbox"][name="privacy"]`);
expect(await privacyInput.count()).toBe(1);
expect(await privacyInput.isChecked()).toBe(false);
expect(await privacyInput.getAttribute("required")).toBe("");
// test if toggleable fields are hidden (not rendered on the page) by default
countryDropdown = form.locator(`select[name="country"]`);
expect(await countryDropdown.count()).toBe(0);
languageDropdown = form.locator(`select[name="language"]`);
expect(await languageDropdown.count()).toBe(0);
// test if submitting the form without filling out the required fields creates validation errors
// wait for submitButton's click event to be attached
const requiredFields = await form.locator(`[required]`);
errorMessages = form.locator(".error-message");
await submitButton.waitFor({ state: "attached" });
await submitButton.click();
expect(await errorMessages.count()).toBe(await requiredFields.count());
// test if putting focus on the email field triggers the toggleable fields
await emailInput.focus();
expect(await countryDropdown.count()).toBe(1);
expect(await languageDropdown.count()).toBe(1);
// filling out all required fields on the form and submitting it eliminates the validation errors
await emailInput.fill(formEmail);
await privacyInput.check();
});
// test all locales we support on the site
for (const locale of locales) {
localeToTest = locale;
async function testThankYouScreen() {
// Form has been submitted successfully.
// - Thank you messages should be displayed.
// - Form should not be displayed
await innerWrapper.waitFor({ state: "visible" });
expect(await innerWrapper.getAttribute("data-submission-status")).toBe(
"success"
);
// test if the thank you message is displayed
const textNodes = innerWrapper.locator("p, h1, h2, h3, h4, h5, h6");
expect(await textNodes.count()).toBeGreaterThan(0);
for (const node of await textNodes.all()) {
expect(await node.textContent()).not.toBe("");
}
// test if the form inside the newsletter module is hidden
const form = moduleContainer.locator("form");
expect(await form.count()).toBe(0);
}
test(`(${locale}) Signing up by filling out only the required fields`, async ({
page,
}) => {
// wait for the request before submitting the form
const apiUrl = await moduleContainer.getAttribute("data-api-url");
const fetchRequest = page.waitForRequest(apiUrl);
await submitButton.click();
expect(await errorMessages.count()).toBe(0);
expect(await innerWrapper.getAttribute("data-submission-status")).toBe(
"pending"
);
// check if the data going to be sent to the API is correct
const postData = (await fetchRequest).postData();
let postDataObj = JSON.parse(postData);
expect(postDataObj.email).toBe(formEmail);
expect(postDataObj.country).toBe("");
// language by default is set to the page's locale
expect(postDataObj.lang).toBe(localeToTest);
expect(postDataObj.source).toBe(pageUrl);
// wait for the fetch response to be received and check if form has been submitted successfully
const fetchResponse = await page.waitForResponse(apiUrl);
expect(fetchResponse.status()).toBe(201);
// check if the thank you screen is correctly displayed
await testThankYouScreen();
});
test(`(${locale}) Signing up by filling out all fields`, async ({
page,
}) => {
const formCountry = "CA";
const formLang = "pt-BR";
await countryDropdown.selectOption(formCountry);
await languageDropdown.selectOption(formLang);
// wait for the request before submitting the form
const apiUrl = await moduleContainer.getAttribute("data-api-url");
const fetchRequest = page.waitForRequest(apiUrl);
await submitButton.click();
expect(await errorMessages.count()).toBe(0);
expect(await innerWrapper.getAttribute("data-submission-status")).toBe(
"pending"
);
// check if the data going to be sent to the API is correct
const postData = (await fetchRequest).postData();
let postDataObj = JSON.parse(postData);
expect(postDataObj.email).toBe(formEmail);
expect(postDataObj.country).toBe(formCountry);
expect(postDataObj.lang).toBe(formLang);
expect(postDataObj.source).toBe(pageUrl);
// wait for the fetch response to be received and check if form has been submitted successfully
const fetchResponse = await page.waitForResponse(apiUrl);
expect(fetchResponse.status()).toBe(201);
// check if the thank you screen is correctly displayed
await testThankYouScreen();
});
}
});