Onboarding: custom newsletter opt-in settings (#20114)

* Settings can process markdown into html

* Update Settings with new onboarding settings

* Async render onboarding newsletter step

* Tweak onboarding design

* Fix broken spec

* Better rendered component test

* Tweaks to match design

* Try to tweak design

* Try having a default state

* Tweak placeholder content

* Better await componentDidMount

* Continue to tweak the design

* ContentRenderer#process should always return Result

* Try more clarity in the partial

* Rubocop
This commit is contained in:
Joshua Wehner 2023-09-26 12:34:19 +02:00 committed by GitHub
parent 7110c69bb5
commit 6a48b486ca
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 260 additions and 60 deletions

View file

@ -73,3 +73,32 @@
right: var(--su-6);
}
}
.onboarding-main {
.email-preferences-wrapper {
h1 {
margin-top: 1.5rem;
margin-bottom: 0.5rem;
line-height: var(--lh-tight);
}
p {
line-height: var(--lh-base);
}
}
.onboarding_newsletter_opt_in_head {
font-weight: 600;
}
.onboarding_newsletter_opt_in_subhead {
color: var(--base-60);
}
.email_newsletter {
border: 1px solid var(--card-border);
border-radius: 0 6px 6px 0;
border-left-width: thick;
border-left-color: var(--accent-brand);
background-color: var(--card-secondary-bg);
}
}

View file

@ -80,6 +80,17 @@ class OnboardingsController < ApplicationController
notifications_updated_response(success, current_user.notification_setting.errors_as_sentence)
end
def newsletter
respond_to do |format|
format.json do
rendered_content = render_to_string(partial: "onboardings/newsletter",
formats: [:html],
layout: false)
render json: { content: rendered_content }
end
end
end
private
def unset_username?

View file

@ -8,15 +8,19 @@ export class EmailPreferencesForm extends Component {
constructor(props) {
super(props);
this.handleChange = this.handleChange.bind(this);
this.onSubmit = this.onSubmit.bind(this);
this.state = {
email_newsletter: false,
content: '<p>Loading...</p>'
};
}
componentDidMount() {
fetch('/onboarding/newsletter')
.then((response) => response.json())
.then((json) => {
this.setState({ content: json['content'] });
});
updateOnboarding('v2: email preferences form');
}
@ -40,15 +44,7 @@ export class EmailPreferencesForm extends Component {
});
}
handleChange(event) {
const { name } = event.target;
this.setState((currentState) => ({
[name]: !currentState[name],
}));
}
render() {
const { email_newsletter } = this.state;
const { prev, slidesCount, currentSlideIndex } = this.props;
return (
<div
@ -61,36 +57,12 @@ export class EmailPreferencesForm extends Component {
aria-labelledby="title"
aria-describedby="subtitle"
>
<div className="onboarding-content terms-and-conditions-wrapper">
<header className="onboarding-content-header">
<h1 id="title" className="title">
Almost there!
</h1>
<h2 id="subtitle" className="subtitle">
Review your email preferences before we continue.
</h2>
</header>
<div
className="onboarding-content email-preferences-wrapper"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: this.state.content }}
/>
<form>
<fieldset>
<legend>Email preferences</legend>
<ul>
<li className="checkbox-item">
<label htmlFor="email_newsletter">
<input
type="checkbox"
id="email_newsletter"
name="email_newsletter"
checked={email_newsletter}
onChange={this.handleChange}
/>
I want to receive weekly newsletter emails.
</label>
</li>
</ul>
</fieldset>
</form>
</div>
<Navigation
prev={prev}
next={this.onSubmit}

View file

@ -33,6 +33,26 @@ describe('EmailPreferencesForm', () => {
username: 'username',
});
const fakeResponse = JSON.stringify({
content: `
<h1>Almost there!</h1>
<form>
<fieldset>
<ul>
<li class="checkbox-item">
<label for="email_newsletter"><input type="checkbox" id="email_newsletter" name="email_newsletter">I want to receive weekly newsletter emails.</label>
</li>
</ul>
</fieldset>
</form>
`,
});
beforeEach(() => {
fetch.resetMocks();
fetch.mockResponseOnce(fakeResponse);
});
beforeAll(() => {
document.head.innerHTML =
'<meta name="csrf-token" content="some-csrf-token" />';
@ -46,20 +66,16 @@ describe('EmailPreferencesForm', () => {
expect(results).toHaveNoViolations();
});
it('should load the appropriate text', () => {
const { queryByText } = renderEmailPreferencesForm();
expect(queryByText(/almost there!/i)).toExist();
expect(
queryByText(/review your email preferences before we continue./i),
).toExist();
expect(queryByText('Email preferences')).toExist();
it('should load the appropriate text', async () => {
const { findByLabelText } = renderEmailPreferencesForm();
await findByLabelText(/receive weekly newsletter/i);
expect(document.body.innerHTML).toMatchSnapshot();
});
it('should show the checkbox unchecked', () => {
const { queryByLabelText } = renderEmailPreferencesForm();
expect(queryByLabelText(/receive weekly newsletter/i).checked).toBe(false);
it('should show the checkbox unchecked', async () => {
const { findByLabelText } = renderEmailPreferencesForm();
const checkbox = await findByLabelText(/receive weekly newsletter/i);
expect(checkbox.checked).toBe(false);
});
it('should render a stepper', () => {

View file

@ -0,0 +1,16 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`EmailPreferencesForm should load the appropriate text 1`] = `
"<div><div data-testid=\\"onboarding-email-preferences-form\\" class=\\"onboarding-main crayons-modal crayons-modal--large\\"><div class=\\"crayons-modal__box\\" role=\\"dialog\\" aria-labelledby=\\"title\\" aria-describedby=\\"subtitle\\"><div class=\\"onboarding-content email-preferences-wrapper\\">
<h1>Almost there!</h1>
<form>
<fieldset>
<ul>
<li class=\\"checkbox-item\\">
<label for=\\"email_newsletter\\"><input type=\\"checkbox\\" id=\\"email_newsletter\\" name=\\"email_newsletter\\">I want to receive weekly newsletter emails.</label>
</li>
</ul>
</fieldset>
</form>
</div><nav class=\\"onboarding-navigation\\"><div class=\\"navigation-content\\"><div class=\\"back-button-container \\"><button data-testid=\\"back-button\\" class=\\"back-button\\" type=\\"button\\" aria-label=\\"Back to previous onboarding step\\"><svg width=\\"24\\" height=\\"24\\" fill=\\"none\\" class=\\"crayons-icon\\" xmlns=\\"http://www.w3.org/2000/svg\\"><path d=\\"M7.828 11H20v2H7.828l5.364 5.364-1.414 1.414L4 12l7.778-7.778 1.414 1.414L7.828 11z\\"></path></svg></button></div><div data-testid=\\"stepper\\" class=\\"stepper\\"><span class=\\"dot active\\"></span><span class=\\"dot active\\"></span><span class=\\"dot active\\"></span><span class=\\"dot active\\"></span><span class=\\"dot active\\"></span></div><button class=\\"next-button\\" type=\\"button\\">Finish</button></div></nav></div></div></div>"
`;

View file

@ -89,6 +89,18 @@ module Constants
description: "",
placeholder: I18n.t("lib.constants.settings.general.meta_keywords.description")
},
onboarding_newsletter_content: {
description: I18n.t("lib.constants.settings.general.onboarding_newsletter_content.description"),
placeholder: I18n.t("lib.constants.settings.general.onboarding_newsletter_content.placeholder")
},
onboarding_newsletter_opt_in_head: {
description: I18n.t("lib.constants.settings.general.onboarding_newsletter_opt_in_head.description"),
placeholder: I18n.t("lib.constants.settings.general.onboarding_newsletter_opt_in_head.placeholder")
},
onboarding_newsletter_opt_in_subhead: {
description: I18n.t("lib.constants.settings.general.onboarding_newsletter_opt_in_subhead.description"),
placeholder: I18n.t("lib.constants.settings.general.onboarding_newsletter_opt_in_subhead.placeholder")
},
payment_pointer: {
description: I18n.t("lib.constants.settings.general.payment.description"),
placeholder: "$pay.somethinglikethis.co/value"

View file

@ -73,7 +73,9 @@ module Settings
if result.nil? # we don't want to accidentally do this for "false"
result ||= default.is_a?(Proc) ? default.call : default
end
result = __send__(:convert_string_to_value_type, type, result, separator: separator)
read_as_type = type == :markdown ? :string : type
result = __send__(:convert_string_to_value_type, read_as_type, result, separator: separator)
result
end
@ -83,10 +85,17 @@ module Settings
var_name = key
record = find_by(var: var_name) || new(var: var_name)
value = __send__(:convert_string_to_value_type, type, value, separator: separator)
record.value = value
record.save!
if type == :markdown
processed = __send__(:convert_string_to_value_type, type, value)
record.value = value
record.save!
__send__(:"#{key}_processed_html=", processed)
else
value = __send__(:convert_string_to_value_type, type, value, separator: separator)
record.value = value
record.save!
end
value
end
@ -127,6 +136,8 @@ module Settings
value.to_f
when :big_decimal
value.to_d
when :markdown
ContentRenderer.new(value).process.processed_html
else
value
end

View file

@ -123,9 +123,21 @@ module Settings
existing_published_article_id: true, allow_nil: true
}
# Onboarding newsletter
setting :onboarding_newsletter_content, type: :markdown
setting :onboarding_newsletter_content_processed_html
setting :onboarding_newsletter_opt_in_head
setting :onboarding_newsletter_opt_in_subhead
setting :default_content_language, type: :string, default: "en",
validates: { inclusion: Languages::Detection.codes }
def self.custom_newsletter_configured?
onboarding_newsletter_content_processed_html.present? &&
onboarding_newsletter_opt_in_head.present? &&
onboarding_newsletter_opt_in_subhead.present?
end
def self.social_media_services
SOCIAL_MEDIA_SERVICES.index_with do |name|
social_media_handles[name]

View file

@ -1,4 +1,4 @@
# renders markdown for Articles, Billboards, Comments
# renders markdown for Articles, Billboards, Comments, Onboarding newsletter content
class ContentRenderer
Result = Struct.new(:front_matter, :reading_time, :processed_html, keyword_init: true)
@ -9,10 +9,10 @@ class ContentRenderer
end
# @param input [String] body_markdown to process
# @param source [Article, Comment, Billboard]
# @param source [optional, possibly Article, Comment, Billboard]
# @param user [User, NilClass] article's or comment's user, nil for Billboard
# @param fixer [Object] fixes the input markdown
def initialize(input, source:, user: nil, fixer: MarkdownProcessor::Fixer::FixAll)
def initialize(input, source: nil, user: nil, fixer: MarkdownProcessor::Fixer::FixAll)
@input = input || ""
@source = source
@user = user

View file

@ -14,6 +14,30 @@
value: Settings::General.suggested_tags.join(","),
placeholder: Constants::Settings::General.details[:suggested_tags][:placeholder] %>
</div>
<div class="crayons-field">
<%= admin_config_label :newsletter_step_heading %>
<%= admin_config_description Constants::Settings::General.details[:onboarding_newsletter_content][:description] %>
<%= f.text_area :onboarding_newsletter_content,
class: "crayons-textfield",
value: Settings::General.onboarding_newsletter_content,
placeholder: Constants::Settings::General.details[:onboarding_newsletter_content][:placeholder] %>
</div>
<div class="crayons-field">
<%= admin_config_label Constants::Settings::General.details[:onboarding_newsletter_opt_in_head][:description] %>
<%= f.text_field :onboarding_newsletter_opt_in_head,
class: "crayons-textfield",
value: Settings::General.onboarding_newsletter_opt_in_head,
placeholder: Constants::Settings::General.details[:onboarding_newsletter_opt_in_head][:placeholder] %>
<%= admin_config_label Constants::Settings::General.details[:onboarding_newsletter_opt_in_subhead][:description] %>
<%= f.text_field :onboarding_newsletter_opt_in_subhead,
class: "crayons-textfield",
value: Settings::General.onboarding_newsletter_opt_in_subhead,
placeholder: Constants::Settings::General.details[:onboarding_newsletter_opt_in_subhead][:placeholder] %>
</div>
</fieldset>
<%= render "update_setting_button", f: f %>
</div>

View file

@ -0,0 +1,63 @@
<% if feature_flag_enabled?(:onboarding_newsletter_content) %>
<% if Settings::General.custom_newsletter_configured? %>
<%= Settings::General.onboarding_newsletter_content_processed_html.html_safe %>
<form>
<fieldset>
<div class="flex email_newsletter mt-5 pt-4 ">
<div class="flex h-5 ml-3">
<input type="checkbox" id="email_newsletter" name="email_newsletter" class="w-4 h-4 mt-1 crayons-checkbox">
</div>
<div class="ml-3">
<label for="email_newsletter" class="font-medium text-gray-900 dark:text-gray-300">
<p class="onboarding_newsletter_opt_in_head" style="font-weight: 600"><%= Settings::General.onboarding_newsletter_opt_in_head %></p>
<p class="onboarding_newsletter_opt_in_subhead mt-1 pb-4"><%= Settings::General.onboarding_newsletter_opt_in_subhead %></p>
</label>
</div>
</div>
</fieldset>
</form>
<% else %>
<%# feature flag is "on" but feature is not configured or only partly configured %>
<h1>Almost there!</h1>
<p>Review your email preferences before we continue.</p>
<form>
<fieldset>
<div class="flex email_newsletter mt-5 pt-3 ">
<div class="flex h-5 ml-3">
<input type="checkbox" id="email_newsletter" name="email_newsletter" class="w-4 h-4 mt-2 crayons-checkbox">
</div>
<div class="ml-2">
<label for="email_newsletter" class="font-medium text-gray-900 dark:text-gray-300">
<p class="mt-1 pb-4">I want to receive weekly newsletter emails</p>
</label>
</div>
</div>
</fieldset>
</form>
<% end %>
<% else %>
<%# feature flag is "off", original behavior %>
<header class="onboarding-content-header">
<h1 id="title" class="title">
Almost there!
</h1>
<h2 id="subtitle" class="subtitle">
Review your email preferences before we continue.
</h2>
</header>
<form>
<fieldset>
<legend>Email preferences</legend>
<ul>
<li class="checkbox-item">
<label for="email_newsletter"><input type="checkbox" id="email_newsletter" name="email_newsletter">I want to receive weekly newsletter emails.</label>
</li>
<li class="checkbox-item">
<label for="email_digest_periodic"><input type="checkbox" id="email_digest_periodic" name="email_digest_periodic">I want to receive a periodic digest of top posts from my tags.</label>
</li>
</ul>
</fieldset>
</form>
<% end %>

View file

@ -144,6 +144,15 @@ en:
description: The Mascot account
meta_keywords:
description: 'List of valid keywords: comma separated, letters only e.g. engineering, development'
onboarding_newsletter_content:
description: "Use this field to set the title and subtitle for your newsletter offering step. Keep it concise."
placeholder: "You can configure image, title and subtitle via markdown"
onboarding_newsletter_opt_in_head:
description: "Newsletter checkbox heading"
placeholder: "E.g., Yes, subscribe me to the weekly newsletter"
onboarding_newsletter_opt_in_subhead:
description: "Newsletter checkbox subheading"
placeholder: "E.g., Rest assured, we respect your privacy. Your email will only be used for our newsletter and won't be shared with others."
payment:
description: 'Used for site-wide web monetization. See: https://github.com/forem/forem/pull/6345'
periodic:

View file

@ -144,6 +144,15 @@ fr:
description: The Mascot account
meta_keywords:
description: 'List of valid keywords: comma separated, letters only e.g. engineering, development'
onboarding_newsletter_content:
description: "Use this field to set the title and subtitle for your newsletter offering step. Keep it concise."
placeholder: "You can configure image, title and subtitle via markdown"
onboarding_newsletter_opt_in_head:
description: "Newsletter checkbox heading"
placeholder: "E.g., Yes, subscribe me to the weekly newsletter"
onboarding_newsletter_opt_in_subhead:
description: "Newsletter checkbox subheading"
placeholder: "E.g., Rest assured, we respect your privacy. Your email will only be used for our newsletter and won't be shared with others."
payment:
description: 'Used for site-wide web monetization. See: https://github.com/forem/forem/pull/6345'
periodic:

View file

@ -178,6 +178,7 @@ Rails.application.routes.draw do
patch :notifications, defaults: { format: :json }
get :tags, defaults: { format: :json }
get :users_and_organizations, defaults: { format: :json }
get :newsletter, defaults: { format: :json }
end
end

View file

@ -30,6 +30,8 @@ RSpec.describe Settings::Base do
setting :float_item, type: :float, default: 7
setting :big_decimal_item, type: :big_decimal, default: 9
setting :default_value_with_block, type: :integer, default: -> { 1 + 1 }
setting :some_markdown, type: :markdown
setting :some_markdown_processed_html
end
end
@ -49,7 +51,7 @@ RSpec.describe Settings::Base do
describe ".keys" do
it "returns all the defined settings", :aggregate_failures do
expect(TestSetting.keys.size).to eq 10
expect(TestSetting.keys.size).to eq 12
expect(TestSetting.keys).to include("host")
expect(TestSetting.keys).to include("default_value_with_block")
end
@ -132,6 +134,19 @@ RSpec.describe Settings::Base do
TestSetting.big_decimal_item = 5
expect(TestSetting.big_decimal_item).to be_an_instance_of(BigDecimal)
end
it "can be coerced from markdown to parsed_html" do
some_markdown = <<~HEREDOC
Hi, Hello!
This is **markdown**.
HEREDOC
TestSetting.some_markdown = some_markdown
processed_html = "<p>Hi, Hello!</p>\n\n<p>This is <strong>markdown</strong>.</p>\n\n"
expect(TestSetting.some_markdown_processed_html).to eq(processed_html)
expect(TestSetting.some_markdown).to eq(some_markdown)
end
end
describe "validations" do