diff --git a/.github/workflows/continous-integration.yml b/.github/workflows/continous-integration.yml index 37503c2a9..b8ac0278c 100644 --- a/.github/workflows/continous-integration.yml +++ b/.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 diff --git a/.github/workflows/visual-regression-testing.yml b/.github/workflows/visual-regression-testing.yml index 77f6bb92c..7da666441 100644 --- a/.github/workflows/visual-regression-testing.yml +++ b/.github/workflows/visual-regression-testing.yml @@ -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 diff --git a/network-api/networkapi/utility/faker/streamfield_provider.py b/network-api/networkapi/utility/faker/streamfield_provider.py index e73a2ba1c..84a923ff8 100644 --- a/network-api/networkapi/utility/faker/streamfield_provider.py +++ b/network-api/networkapi/utility/faker/streamfield_provider.py @@ -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 = [] diff --git a/network-api/networkapi/wagtailcustomization/factories/__init__.py b/network-api/networkapi/wagtailcustomization/factories/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/network-api/networkapi/wagtailcustomization/factories/snippets.py b/network-api/networkapi/wagtailcustomization/factories/snippets.py new file mode 100644 index 000000000..4d4557723 --- /dev/null +++ b/network-api/networkapi/wagtailcustomization/factories/snippets.py @@ -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 diff --git a/network-api/networkapi/wagtailpages/factory/blog.py b/network-api/networkapi/wagtailpages/factory/blog.py index 21eba3180..12915648d 100644 --- a/network-api/networkapi/wagtailpages/factory/blog.py +++ b/network-api/networkapi/wagtailpages/factory/blog.py @@ -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", diff --git a/network-api/networkapi/wagtailpages/factory/customblocks/__init__.py b/network-api/networkapi/wagtailpages/factory/customblocks/__init__.py index 51f724472..1da825405 100644 --- a/network-api/networkapi/wagtailpages/factory/customblocks/__init__.py +++ b/network-api/networkapi/wagtailpages/factory/customblocks/__init__.py @@ -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, +) diff --git a/network-api/networkapi/wagtailpages/factory/customblocks/newsletter_signup_block.py b/network-api/networkapi/wagtailpages/factory/customblocks/newsletter_signup_block.py new file mode 100644 index 000000000..6e8ccd863 --- /dev/null +++ b/network-api/networkapi/wagtailpages/factory/customblocks/newsletter_signup_block.py @@ -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) diff --git a/network-api/networkapi/wagtailpages/factory/signup.py b/network-api/networkapi/wagtailpages/factory/signup.py index 067fc1f91..03fcf707d 100644 --- a/network-api/networkapi/wagtailpages/factory/signup.py +++ b/network-api/networkapi/wagtailpages/factory/signup.py @@ -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 diff --git a/network-api/networkapi/wagtailpages/migrations/0114_add_blog_newsletter_signup.py b/network-api/networkapi/wagtailpages/migrations/0114_add_blog_newsletter_signup.py new file mode 100644 index 000000000..09ac27d76 --- /dev/null +++ b/network-api/networkapi/wagtailpages/migrations/0114_add_blog_newsletter_signup.py @@ -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")}, + }, + ), + ] diff --git a/network-api/networkapi/wagtailpages/models.py b/network-api/networkapi/wagtailpages/models.py index 4bb3d2df6..f701aa535 100644 --- a/network-api/networkapi/wagtailpages/models.py +++ b/network-api/networkapi/wagtailpages/models.py @@ -58,6 +58,7 @@ from .pagemodels.campaign_index import CampaignIndexPage from .pagemodels.campaigns import ( CTA, BanneredCampaignPage, + BlogSignup, CampaignPage, OpportunityPage, Petition, diff --git a/network-api/networkapi/wagtailpages/pagemodels/blog/blog.py b/network-api/networkapi/wagtailpages/pagemodels/blog/blog.py index 3ddd11b24..3d0dcd438 100644 --- a/network-api/networkapi/wagtailpages/pagemodels/blog/blog.py +++ b/network-api/networkapi/wagtailpages/pagemodels/blog/blog.py @@ -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, diff --git a/network-api/networkapi/wagtailpages/pagemodels/campaigns.py b/network-api/networkapi/wagtailpages/pagemodels/campaigns.py index d2d7842d4..2bf6cecf6 100644 --- a/network-api/networkapi/wagtailpages/pagemodels/campaigns.py +++ b/network-api/networkapi/wagtailpages/pagemodels/campaigns.py @@ -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"), diff --git a/network-api/networkapi/wagtailpages/pagemodels/customblocks/__init__.py b/network-api/networkapi/wagtailpages/pagemodels/customblocks/__init__.py index 58be26b73..a2058a940 100644 --- a/network-api/networkapi/wagtailpages/pagemodels/customblocks/__init__.py +++ b/network-api/networkapi/wagtailpages/pagemodels/customblocks/__init__.py @@ -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 diff --git a/network-api/networkapi/wagtailpages/pagemodels/customblocks/newsletter_signup_block.py b/network-api/networkapi/wagtailpages/pagemodels/customblocks/newsletter_signup_block.py index 72939289e..ecaf55c85 100644 --- a/network-api/networkapi/wagtailpages/pagemodels/customblocks/newsletter_signup_block.py +++ b/network-api/networkapi/wagtailpages/pagemodels/customblocks/newsletter_signup_block.py @@ -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" diff --git a/network-api/networkapi/wagtailpages/templates/wagtailpages/blocks/blog_newsletter_signup_block.html b/network-api/networkapi/wagtailpages/templates/wagtailpages/blocks/blog_newsletter_signup_block.html new file mode 100644 index 000000000..203ce49a0 --- /dev/null +++ b/network-api/networkapi/wagtailpages/templates/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 %} +
+{% endblock block_content %} diff --git a/network-api/networkapi/wagtailpages/tests/customblocks/test_newsletter_signup_blocks.py b/network-api/networkapi/wagtailpages/tests/customblocks/test_newsletter_signup_blocks.py new file mode 100644 index 000000000..658a5102f --- /dev/null +++ b/network-api/networkapi/wagtailpages/tests/customblocks/test_newsletter_signup_blocks.py @@ -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) diff --git a/source/images/glyphs/letter.svg b/source/images/glyphs/letter.svg new file mode 100644 index 000000000..476675b89 --- /dev/null +++ b/source/images/glyphs/letter.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/source/js/common/inject-react/index.js b/source/js/common/inject-react/index.js index 15346e0b6..d96a36371 100644 --- a/source/js/common/inject-react/index.js +++ b/source/js/common/inject-react/index.js @@ -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); }; diff --git a/source/js/common/inject-react/newsletter-signup-module.js b/source/js/common/inject-react/newsletter-signup-module.js new file mode 100644 index 000000000..f822a9932 --- /dev/null +++ b/source/js/common/inject-react/newsletter-signup-module.js @@ -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( + resolve()} /> + ); + }) + ); + }); +}; diff --git a/source/js/components/newsletter-signup/atoms/button-submit.jsx b/source/js/components/newsletter-signup/atoms/button-submit.jsx new file mode 100644 index 000000000..faf90514c --- /dev/null +++ b/source/js/components/newsletter-signup/atoms/button-submit.jsx @@ -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 ( + + ); +}; + +ButtonSubmit.propTypes = { + children: PropTypes.node.isRequired, + widthClasses: PropTypes.string, +}; + +export default ButtonSubmit; diff --git a/source/js/components/newsletter-signup/atoms/description.jsx b/source/js/components/newsletter-signup/atoms/description.jsx new file mode 100644 index 000000000..9fab0a8fc --- /dev/null +++ b/source/js/components/newsletter-signup/atoms/description.jsx @@ -0,0 +1,22 @@ +import React from "react"; +import PropTypes from "prop-types"; + +const Description = ({ content }) => { + if (typeof content === "string") { + return ( +
+ ); + } + + return
{content}
; +}; + +Description.propTypes = { + content: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired, +}; + +export default Description; diff --git a/source/js/components/newsletter-signup/atoms/heading.jsx b/source/js/components/newsletter-signup/atoms/heading.jsx new file mode 100644 index 000000000..d1a5e0238 --- /dev/null +++ b/source/js/components/newsletter-signup/atoms/heading.jsx @@ -0,0 +1,14 @@ +import React from "react"; +import PropTypes from "prop-types"; + +const Heading = ({ level = 3, children }) => { + const TagName = `h${level}`; + return {children}; +}; + +Heading.propTypes = { + level: PropTypes.number, + children: PropTypes.node.isRequired, +}; + +export default Heading; diff --git a/source/js/components/newsletter-signup/atoms/input-checkbox.jsx b/source/js/components/newsletter-signup/atoms/input-checkbox.jsx new file mode 100644 index 000000000..2287610ae --- /dev/null +++ b/source/js/components/newsletter-signup/atoms/input-checkbox.jsx @@ -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 ; +}; + +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; diff --git a/source/js/components/newsletter-signup/atoms/input-email.jsx b/source/js/components/newsletter-signup/atoms/input-email.jsx new file mode 100644 index 000000000..76eea9ce6 --- /dev/null +++ b/source/js/components/newsletter-signup/atoms/input-email.jsx @@ -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 = ( + + ); + let errorNotice = null; + + if (errorMessage) { + inputField = ( +
+ {inputField} +
+ +
+
+ ); + + errorNotice = ( + <> +

+ {errorMessage} +

+ + ); + } + + return ( +
+ {inputField} + {errorNotice} +
+ ); +}; + +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; diff --git a/source/js/components/newsletter-signup/atoms/label.jsx b/source/js/components/newsletter-signup/atoms/label.jsx new file mode 100644 index 000000000..66b2e7e2b --- /dev/null +++ b/source/js/components/newsletter-signup/atoms/label.jsx @@ -0,0 +1,18 @@ +import React from "react"; +import PropTypes from "prop-types"; + +const Label = ({ htmlFor, children, classes }) => { + return ( + + ); +}; + +Label.propTypes = { + htmlFor: PropTypes.string.isRequired, + children: PropTypes.node.isRequired, + classes: PropTypes.string, +}; + +export default Label; diff --git a/source/js/components/newsletter-signup/atoms/select.jsx b/source/js/components/newsletter-signup/atoms/select.jsx new file mode 100644 index 000000000..c87bf07f4 --- /dev/null +++ b/source/js/components/newsletter-signup/atoms/select.jsx @@ -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.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; diff --git a/source/js/components/newsletter-signup/atoms/validation-error.jsx b/source/js/components/newsletter-signup/atoms/validation-error.jsx new file mode 100644 index 000000000..97a0867d4 --- /dev/null +++ b/source/js/components/newsletter-signup/atoms/validation-error.jsx @@ -0,0 +1,7 @@ +import React from "react"; + +const ValidationError = ({ message }) => { + return

{message}

; +}; + +export default ValidationError; diff --git a/source/js/components/newsletter-signup/data/country-options.js b/source/js/components/newsletter-signup/data/country-options.js new file mode 100644 index 000000000..a7abe03c3 --- /dev/null +++ b/source/js/components/newsletter-signup/data/country-options.js @@ -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; diff --git a/source/js/components/newsletter-signup/data/language-options.js b/source/js/components/newsletter-signup/data/language-options.js new file mode 100644 index 000000000..81ff1e20c --- /dev/null +++ b/source/js/components/newsletter-signup/data/language-options.js @@ -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; diff --git a/source/js/components/newsletter-signup/molecules/input-checkbox-with-label.jsx b/source/js/components/newsletter-signup/molecules/input-checkbox-with-label.jsx new file mode 100644 index 000000000..bc8faa75d --- /dev/null +++ b/source/js/components/newsletter-signup/molecules/input-checkbox-with-label.jsx @@ -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 ( +
+ +
+
+ + {errorMessage && ( + + )} +
+ {errorMessage && ( +

+ {errorMessage} +

+ )} +
+
+ ); +}; + +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; diff --git a/source/js/components/newsletter-signup/organisms/blog-body-signup.jsx b/source/js/components/newsletter-signup/organisms/blog-body-signup.jsx new file mode 100644 index 000000000..b6983f4ee --- /dev/null +++ b/source/js/components/newsletter-signup/organisms/blog-body-signup.jsx @@ -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 {this.props.ctaHeader}; + } + + renderDescription() { + if (!this.props.ctaDescription) return null; + + return ; + } + + renderEmailField() { + const name = "email"; + const outerMarginClasses = classNames({ + [FIELD_MARGIN_CLASSES]: true, + "tw-has-error": !!this.props.errors[name], + }); + + return ( + 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 ( + <> + this.handleLanguageChange(event)} + required={false} + outerMarginClasses={FIELD_MARGIN_CLASSES} + /> + + ); + } + + renderPrivacyCheckbox() { + const name = "privacy"; + + return ( + this.handlePrivacyChange(event)} + required={true} + errorMessage={this.props.errors[name]} + /> + ); + } + + renderForm() { + if (this.props.hideForm) return null; + + return ( +
this.props.onSubmit(event, this.state.formData)} + > +
+
+
+ {this.renderEmailField()} + {this.state.showAllFields && this.renderAdditionalFields()} +
+
{this.renderPrivacyCheckbox()}
+
+
+ + {getText("Sign Up")} + +
+
+
+ ); + } + + render() { + return ( +
+ {this.renderHeader()} + {this.renderDescription()} + {this.renderForm()} +
+ ); + } +} + +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); diff --git a/source/js/components/newsletter-signup/organisms/with-submission-logic.jsx b/source/js/components/newsletter-signup/organisms/with-submission-logic.jsx new file mode 100644 index 000000000..693cf46b4 --- /dev/null +++ b/source/js/components/newsletter-signup/organisms/with-submission-logic.jsx @@ -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 = ( + <> +

{getText(`confirm your email opt-in`)}

+

{getText(`manage your subscriptions`)}

+ + ); + } + + return message; + } + + /** + * Render the wrapped component with additional props + */ + render() { + let { ctaHeader, ctaDescription, ...otherProps } = this.props; + + return ( + 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; diff --git a/tests/integration/newsletter/001-blog-body.spec.js b/tests/integration/newsletter/001-blog-body.spec.js new file mode 100644 index 000000000..f6a0753a0 --- /dev/null +++ b/tests/integration/newsletter/001-blog-body.spec.js @@ -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(); + }); + } +});