[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:
Родитель
5812f67517
Коммит
ca6e637c02
|
@ -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();
|
||||
});
|
||||
}
|
||||
});
|
Загрузка…
Ссылка в новой задаче