Add HTML variants for cta optimization and other nav bar adjustments (#970)

* Add HTML variants for cta optimization and other nav bar adjustments

* Fix 1==1 non-random mistake

* Spruce up org call-to-action

* Change default text color

* Add html variant trial and success request specs

* Fix article sidebar caching issue

* One line adjustment

* Modify schema

* Add include ActionView::Helpers::TagHelper to user_tag_spec

* Modify follow_button to remove session context

* Dummy commit

* Change Edit Article to Edit Post

* Dummy commit

* Fix rubocop concerns

* Fix rubocop style
This commit is contained in:
Ben Halpern 2018-10-22 17:03:57 -04:00 committed by GitHub
parent 7399e022da
commit 302e8f202d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
46 changed files with 924 additions and 131 deletions

View file

@ -65,7 +65,7 @@ guard :rspec, rspec_options do
[
rspec.spec.call("routing/#{m[1]}_routing"),
rspec.spec.call("controllers/#{m[1]}_controller"),
rspec.spec.call("acceptance/#{m[1]}"),
rspec.spec.call("acceptance/#{m[1]}")
]
end

View file

@ -12,9 +12,6 @@ function initializeBaseTracking() {
wait++;
if (window.ga && ga.create) {
ga('create', '<%= ApplicationConfig["GA_TRACKING_ID"] %>', 'auto');
if (!checkUserLoggedIn() && Math.random() < 0.35) {
ga('require', '<%= ApplicationConfig["GA_OPTIMIZE_ID"] %>');
}
ga('send', 'pageview', location.pathname + location.search);
if (document.location.search.indexOf('newly-registered-user=true') > -1) {
ga('send', 'event', 'registration', 'New user registration', location.pathname, null);
@ -29,6 +26,7 @@ function initializeBaseTracking() {
}, 25);
facebookTrackingScript();
eventListening();
trackCustomImpressions();
}
function fallbackActivityRecording() {
@ -107,4 +105,50 @@ function logImpressions() {
var data = el.dataset.featuredArticle;
ga('send', 'event', 'view', 'featured-feed-impression', data, null);
}
}
function trackCustomImpressions() {
setTimeout(function(){
var stickyNav = document.getElementById('article-show-primary-sticky-nav');
var sidebarHTMLVariant = document.getElementById('html-variant-article-show-sidebar');
var ArticleElement = document.getElementById('article-body');
var tokenMeta = document.querySelector("meta[name='csrf-token']")
var randomNumber = Math.floor(Math.random() * 10); // 1 in 10; Only track 1 in 10 impressions
var isBot = /bot|google|baidu|bing|msn|duckduckbot|teoma|slurp|yandex/i.test(navigator.userAgent) // is crawler
if (sidebarHTMLVariant && ArticleElement && tokenMeta && !isBot) {
var dataBody = {
html_variant_id: sidebarHTMLVariant.dataset.variantId,
article_id: ArticleElement.dataset.articleId,
};
var csrfToken = tokenMeta.getAttribute('content');
if (randomNumber === 1) {
window.fetch('/html_variant_trials', {
method: 'POST',
headers: {
'X-CSRF-Token': csrfToken,
'Content-Type': 'application/json',
},
body: JSON.stringify(dataBody),
credentials: 'same-origin',
});
}
var successLinks = stickyNav.querySelectorAll('a,button'); //track all links and button clicks within nav
for(var i = 0; i < successLinks.length; i++)
{
successLinks[i].addEventListener('click', function() { trackHtmlVariantSuccess(dataBody, csrfToken) });
}
}
}, 1500)
}
function trackHtmlVariantSuccess(dataBody, csrfToken) {
window.fetch('/html_variant_successes', {
method: 'POST',
headers: {
'X-CSRF-Token': csrfToken,
'Content-Type': 'application/json',
},
body: JSON.stringify(dataBody),
credentials: 'same-origin',
})
}

View file

@ -663,7 +663,7 @@ header{
button{
background:transparent;
margin: 0px 0.22vw;
padding:calc(0.05vw + 3px) calc(0.88vw + 3px);
padding: 3px calc(0.88vw + 3px);
border-radius:100px;
border:2px solid darken($light-gray, 7%);
font-family: $helvetica-condensed;
@ -702,7 +702,6 @@ header{
}
&.user-activated {
border:2px solid darken($bold-blue, 40%);
padding:calc(0.05vw + 3px) calc(0.88vw + 3px);
background: linear-gradient(60deg, #f4d2d2, #efb6b6);
img{
filter: none;
@ -1004,13 +1003,15 @@ header{
@keyframes reaction {
0% {
padding:calc(0.05vw + 3px) calc(0.88vw + 3px);
padding-left: calc(0.88vw + 3px);
padding-right: calc(0.88vw + 3px);
}
50% {
padding:calc(0.05vw + 3px) calc(1.1vw + 3px);
margin: 0px 0.00vw;
padding-left: calc(1.1vw + 3px);
padding-left: calc(1.1vw + 3px);
}
100% {
padding:calc(0.05vw + 3px) calc(0.88vw + 3px);
padding-left: calc(0.88vw + 3px);
padding-right: calc(0.88vw + 3px);
}
}

View file

@ -108,7 +108,6 @@
}
}
label {
width: 160px;
display:inline-block;
vertical-align:top;
padding:14px 0px 8px;

View file

@ -39,13 +39,12 @@
.primary-sticky-nav-author-follow{
padding-top:8px;
button{
width:136px;
font-size:1.1em;
width:100%;
font-size:1.25em;
border:0px;
border-radius:3px;
padding: 3px;
height: 29px;
height: 44px;
}
}
}
@ -112,38 +111,67 @@
box-shadow: 3px 4px 0px darken($light-medium-gray, 17%);
}
}
.user-metadata-details {
margin-top: 10px;
margin-left: 2px;
font-family: $monospace;
.row{
padding: calc(5px + 0.2vw) 0px;
display: inline-block;
width: 96%;
}
.key{
font-size: 0.8em;
font-weight: 800;
margin-bottom: 2px;
color: $medium-gray;
}
.value{
display: inline-block;
font-size: 0.92em;
margin-bottom: 4px;
}
}
.html-variant-wrapper{
margin: 10px;
}
}
.primary-sticky-nav-org{
position:relative;
.sticky-box-connector{
position:absolute;
top: - 10px;
height: 10px;
width: 5px;
background: $outline-color;
left: 15px;
&.sticky-box-right-connector{
left: auto;
right: 15px;
}
}
.primary-sticky-nav-org-summary{
font-weight: 400;
color: $medium-gray;
padding: 0px 0px 5px;
font-size:0.88em;
color: $black;
padding: 4px 0px;
font-size:0.95em;
margin-top: 20px;
border-top: 1px solid $light-medium-gray;
p{
margin:5px 0px;
margin:12px 0px;
}
a.primary-sticky-nav-org-cta-link{
width:100%;
display: block;
font-size:1.25em;
border:0px;
border-radius:3px;
padding: 8px 0px 3px;
margin-top: 20px;
background: white;
color: $black;
font-family: $helvetica-condensed;
border: 1px solid darken($light-medium-gray, 7%);
text-align: center;
.primary-sticky-nav-org-cta-link-domain-small{
display: block;
padding-top: 2px;
font-size:0.8em;
color: darken($light-medium-gray, 4%);
font-family: $monospace;
}
&:hover {
border: 1px solid darken($light-medium-gray, 12%);
box-shadow: $shadow;
transition: all 0.1s;
}
}
}
button{
min-width:135px;
font-size:1.1em;
border:0px;
border-radius:3px;
padding: 3px;
display: block;
margin-top:18px;
}
}

View file

@ -61,12 +61,10 @@ class GoogleAnalytics
metrics: [Metric.new(expression: metrics_string)],
dimensions: [Dimension.new(name: "ga:segment")],
segments: [Segment.new(segment_id: "gaid::-1"),
Segment.new(segment_id: "gaid::-2"),
Segment.new(segment_id: "gaid::-19"),
Segment.new(segment_id: "gaid::-7")],
date_ranges: [
DateRange.new(start_date: @start_date, end_date: "2020-01-01"),
],
Segment.new(segment_id: "gaid::-2"),
Segment.new(segment_id: "gaid::-19"),
Segment.new(segment_id: "gaid::-7")],
date_ranges: [DateRange.new(start_date: @start_date, end_date: "2020-01-01")],
)
end

View file

@ -0,0 +1,21 @@
module Admin
class HtmlVariantsController < Admin::ApplicationController
# To customize the behavior of this controller,
# you can overwrite any of the RESTful actions. For example:
#
# def index
# super
# @resources = HtmlVariant.
# page(params[:page]).
# per(10)
# end
# Define a custom finder by overriding the `find_resource` method:
# def find_resource(param)
# HtmlVariant.find_by!(slug: param)
# end
# See https://administrate-prototype.herokuapp.com/customizing_controller_actions
# for more information
end
end

View file

@ -0,0 +1,6 @@
class HtmlVariantSuccessesController < ApplicationController
def create
HtmlVariantSuccess.delay.create(html_variant_id: params[:html_variant_id], article_id: params[:article_id])
head :ok
end
end

View file

@ -0,0 +1,6 @@
class HtmlVariantTrialsController < ApplicationController
def create
HtmlVariantTrial.delay.create(html_variant_id: params[:html_variant_id], article_id: params[:article_id])
head :ok
end
end

View file

@ -0,0 +1,53 @@
class HtmlVariantsController < ApplicationController
after_action :verify_authorized
def index
authorize HtmlVariant
@user_variants = current_user.html_variants.order("created_at DESC")
@all_published_variants = HtmlVariant.where(published: true).order("created_at DESC")
@preview_path = Article.where(featured: true, published: true).order("published_at DESC").first&.path.to_s
end
def new
authorize HtmlVariant
@html_variant = HtmlVariant.new
if params[:fork_id]
@fork = HtmlVariant.find(params[:fork_id])
@html_variant.name = @fork.name + " FORK"
@html_variant.html = @fork.html
end
end
def edit
@html_variant = HtmlVariant.find(params[:id])
authorize @html_variant
end
def create
authorize HtmlVariant
@html_variant = HtmlVariant.new(html_variant_params)
@html_variant.group = "article_show_sidebar_cta"
@html_variant.user_id = current_user.id
if @html_variant.save
redirect_to "/html_variants"
else
render :new
end
end
def update
@html_variant = HtmlVariant.find(params[:id])
authorize @html_variant
if @html_variant.update(html_variant_params)
redirect_to "/html_variants"
else
render :edit
end
end
private
def html_variant_params
params.require(:html_variant).permit(policy(HtmlVariant).permitted_attributes)
end
end

View file

@ -25,6 +25,7 @@ class DashboardManifest
display_ads
badges
badge_achievements
html_variants
].freeze
# DASHBOARDS = [
# :users,

View file

@ -0,0 +1,78 @@
require "administrate/base_dashboard"
class HtmlVariantDashboard < Administrate::BaseDashboard
# ATTRIBUTE_TYPES
# a hash that describes the type of each of the model's fields.
#
# Each different type represents an Administrate::Field object,
# which determines how the attribute is displayed
# on pages throughout the dashboard.
ATTRIBUTE_TYPES = {
user: Field::BelongsTo,
html_variant_trials: Field::HasMany,
html_variant_successes: Field::HasMany,
id: Field::Number,
group: Field::String,
name: Field::String,
html: Field::Text,
target_tag: Field::String,
success_rate: Field::Number.with_options(decimals: 2),
published: Field::Boolean,
approved: Field::Boolean,
created_at: Field::DateTime,
updated_at: Field::DateTime,
}.freeze
# COLLECTION_ATTRIBUTES
# an array of attributes that will be displayed on the model's index page.
#
# By default, it's limited to four items to reduce clutter on index pages.
# Feel free to add, remove, or rearrange items.
COLLECTION_ATTRIBUTES = [
:user,
:html_variant_trials,
:html_variant_successes,
:id,
].freeze
# SHOW_PAGE_ATTRIBUTES
# an array of attributes that will be displayed on the model's show page.
SHOW_PAGE_ATTRIBUTES = [
:user,
:html_variant_trials,
:html_variant_successes,
:id,
:group,
:name,
:html,
:target_tag,
:success_rate,
:published,
:approved,
:created_at,
:updated_at,
].freeze
# FORM_ATTRIBUTES
# an array of attributes that will be displayed
# on the model's form (`new` and `edit`) pages.
FORM_ATTRIBUTES = [
:user,
:html_variant_trials,
:html_variant_successes,
:group,
:name,
:html,
:target_tag,
:success_rate,
:published,
:approved,
].freeze
# Overwrite this method to customize how html variants are displayed
# across all pages of the admin dashboard.
#
# def display_resource(html_variant)
# "HtmlVariant ##{html_variant.id}"
# end
end

View file

@ -44,8 +44,8 @@ class UserDecorator < ApplicationDecorator
text: "#ffffff"
},
{
bg: "#edebf6",
text: " #070126"
bg: "#2e0338",
text: " #ffffff"
},
{
bg: "#080E3B",

View file

@ -29,6 +29,7 @@ module ApplicationHelper
comments
notifications
reading_list_items
html_variants
).include?(controller_name)
end
@ -153,7 +154,7 @@ module ApplicationHelper
end
def follow_button(followable, style = "full")
tag :button,
tag :button, #Yikes
class: "cta follow-action-button",
data: {
info: { id: followable.id, className: followable.class.name, style: style }.to_json,

View file

@ -1,5 +1,6 @@
class UserTag < LiquidTagBase
include ApplicationHelper
include ActionView::Helpers::TagHelper
attr_reader :user
def initialize(_tag_name, user, _tokens)

View file

@ -0,0 +1,40 @@
class HtmlVariant < ApplicationRecord
validates :html, presence: true
validates :name, uniqueness: true
validates :group, inclusion: { in: %w(article_show_sidebar_cta) }
validates :success_rate, presence: true
validate :no_edits
belongs_to :user, optional: true
has_many :html_variant_trials
has_many :html_variant_successes
def calculate_success_rate!
self.success_rate = html_variant_successes.size.to_f / (html_variant_trials.size * 10.0) # x10 because we only capture every 10th
save!
end
def self.find_for_test(tags = [])
tags_array = tags + ["", nil]
if rand(10) == 1 # 10% return completely random
find_random_for_test(tags_array)
else # 90% chance return one in top 10
find_top_for_test(tags_array)
end
end
def self.find_top_for_test(tags_array)
where(group: "article_show_sidebar_cta", approved: true, published: true, target_tag: tags_array).order("success_rate DESC").limit(rand(1..10)).sample
end
def self.find_random_for_test(tags_array)
where(group: "article_show_sidebar_cta", approved: true, published: true, target_tag: tags_array).order("RANDOM()").first
end
private
def no_edits
if (approved && html_changed? || name_changed? || group_changed?) && persisted?
errors.add(:base, "cannot change once published and approved")
end
end
end

View file

@ -0,0 +1,5 @@
class HtmlVariantSuccess < ApplicationRecord
validates :html_variant_id, presence: true
belongs_to :html_variant
belongs_to :article, optional: true
end

View file

@ -0,0 +1,5 @@
class HtmlVariantTrial < ApplicationRecord
validates :html_variant_id, presence: true
belongs_to :html_variant
belongs_to :article, optional: true
end

View file

@ -36,7 +36,7 @@ class Organization < ApplicationRecord
validates :cta_button_url,
url: { allow_blank: true, no_local: true, schemes: ["https", "http"] }, if: :approved
validates :cta_button_text, length: { maximum: 12 }
validates :cta_body_markdown, length: { maximum: 140 }
validates :cta_body_markdown, length: { maximum: 256 }
before_save :remove_at_from_usernames
after_save :bust_cache
before_save :generate_secret

View file

@ -32,6 +32,7 @@ class User < ApplicationRecord
has_many :chat_channels, through: :chat_channel_memberships
has_many :push_notification_subscriptions, dependent: :destroy
has_many :feedback_messages
has_many :html_variants, dependent: :destroy
has_many :mentor_relationships_as_mentee,
class_name: "MentorRelationship", foreign_key: "mentee_id"
has_many :mentor_relationships_as_mentor,

View file

@ -0,0 +1,33 @@
class HtmlVariantPolicy < ApplicationPolicy
def index?
user_admin?
end
def show?
user_admin?
end
def edit?
user_admin?
end
def update?
user_admin?
end
def new?
user_admin?
end
def create?
user_admin?
end
def destroy?
user_admin?
end
def permitted_attributes
%i[html name published approved target_tag]
end
end

View file

@ -1,6 +1,8 @@
<% @sticky_collection = StickyArticleCollection.new(@article, @organization || @user) %>
<% @sticky_articles = @sticky_collection.suggested_stickies %>
<% @user_stickies = @sticky_collection.user_stickies %>
<% if user_signed_in? %>
<% @sticky_collection = StickyArticleCollection.new(@article, @organization || @user) %>
<% @sticky_articles = @sticky_collection.suggested_stickies %>
<% @user_stickies = @sticky_collection.user_stickies %>
<% end %>
<% @actor = @article.organization || @article.user %>
<style>
@ -27,7 +29,7 @@
}
</style>
<div class="primary-sticky-nav">
<div class="primary-sticky-nav" id="article-show-primary-sticky-nav">
<div class="primary-sticky-nav-element primary-sticky-nav-author">
<a href="<%= @actor.path %>"><img src="<%= ProfileImage.new(@actor).get(90) %>" class="primary-sticky-nav-author-top-profile-image" /></a>
<span class="primary-sticky-nav-author-name">
@ -38,61 +40,76 @@
<div class="primary-sticky-nav-author-summary">
<% if @actor.tag_line.present? %>
<%= truncate (@actor.tag_line || @actor.summary || "Posts in this tag"), length: 200 %>
<% else %>
Member since <%= @actor.created_at.strftime("%b %e, %Y") %>
<% end %>
</div>
<div class="primary-sticky-nav-author-follow">
<%= follow_button(@actor) %>
</div>
</div>
<% if @actor.class.name == "Organization" && @actor.approved_and_filled_out_cta? %>
<div class="primary-sticky-nav-element primary-sticky-nav-author primary-sticky-nav-org">
<div class="sticky-box-connector"></div>
<div class="sticky-box-connector sticky-box-right-connector"></div>
<% if @actor.class.name == "User" %>
<%= render "articles/user_metadata" %>
<% elsif @actor.class.name == "Organization" && @actor.approved_and_filled_out_cta? %>
<div class="primary-sticky-nav-org-summary">
<%= @actor.cta_processed_html.html_safe %>
<button class="cta primary-sticky-nav-org-button">
<%= link_to @actor.cta_button_text.upcase || "LEARN MORE", @actor.cta_button_url %>
</button>
<a href="<%= @actor.cta_button_url || "LEARN MORE" %>" class="primary-sticky-nav-org-cta-link">
<%= @actor.cta_button_text.upcase %>
<span class="primary-sticky-nav-org-cta-link-domain-small"><%= Addressable::URI.parse(@actor.cta_button_url).domain %></span>
</a>
</div>
<% end %>
</div>
<% if params[:html_variant_id] %>
<% @html_variant = HtmlVariant.where(group: "article_show_sidebar_cta", id: params[:html_variant_id].to_i ).first %>
<% else %>
<% @html_variant = HtmlVariant.find_for_test(@article.cached_tag_list_array) %>
<% end %>
<% if ( params[:html_variant_id] || !user_signed_in? ) && @html_variant %>
<div class="html-variant-wrapper" id="html-variant-article-show-sidebar" data-variant-id="<%= @html_variant.id %>" >
<%= @html_variant.html.html_safe %>
</div>
<% end %>
<% if @user_stickies && @user_stickies.any? %>
<div class="primary-sticky-nav-title">
<% if @actor.username.size < 18 %>
More from <a href="<%= @actor.path %>">@<%= @actor.username %></a>
<% else %>
More from<br/><a href="<%= @actor.path %>">@<%= @actor.username %></a>
<% end %>
</div>
<% @user_stickies.each do |article| %>
<a class="primary-sticky-nav-element primary-sticky-nav-author-element" href="<%= article.path %>">
<img src="<%= ProfileImage.new(article.user).get(90) %>" class="primary-sticky-nav-profile-image" />
<%= article.title %>
<div class="primary-sticky-nav-element-details">
<% article.decorate.cached_tag_list_array.each do |tag| %>
<span>#<%= tag %></span>
<% end %>
</div>
</a>
<% if user_signed_in? && @user_stickies && @user_stickies.any? %>
<% @user_stickies.each_with_index do |article, index| %>
<div class="primary-sticky-nav-element-wrapper">
<% if index == 0 %>
<div class="primary-sticky-nav-title">
<% if @actor.username.size < 18 %>
More from <a href="<%= @actor.path %>">@<%= @actor.username %></a>
<% else %>
More from<br/><a href="<%= @actor.path %>">@<%= @actor.username %></a>
<% end %>
</div>
<% end %>
<a class="primary-sticky-nav-element primary-sticky-nav-author-element" href="<%= article.path %>">
<img src="<%= ProfileImage.new(article.user).get(90) %>" class="primary-sticky-nav-profile-image" />
<%= article.title %>
<div class="primary-sticky-nav-element-details">
<% article.decorate.cached_tag_list_array.each do |tag| %>
<span>#<%= tag %></span>
<% end %>
</div>
</a>
</div>
<% end %>
<% end %>
<% if @sticky_articles %>
<div class="primary-sticky-nav-title">
<img src="<%= asset_path "emoji/apple-fire.png"%>"/> Trending on <a href="https://dev.to">dev.to</a>
</div>
<% @sticky_articles.each do |article| %>
<a class="primary-sticky-nav-element" href="<%= article.path %>">
<img src="<%= ProfileImage.new(article.user).get(90) %>" class="primary-sticky-nav-profile-image" />
<%= article.title %>
<div class="primary-sticky-nav-element-details">
<% article.decorate.cached_tag_list_array.each do |tag| %>
<span>#<%= tag %></span>
<% end %>
</div>
</a>
<% if user_signed_in? && @sticky_articles %>
<% @sticky_articles.each_with_index do |article, index| %>
<div class="primary-sticky-nav-element-wrapper">
<% if index == 0 %>
<div class="primary-sticky-nav-title">
<img src="<%= asset_path "emoji/apple-fire.png"%>"/> Trending on <a href="https://dev.to">dev.to</a>
</div>
<% end %>
<a class="primary-sticky-nav-element" href="<%= article.path %>">
<img src="<%= ProfileImage.new(article.user).get(90) %>" class="primary-sticky-nav-profile-image" />
<%= article.title %>
<div class="primary-sticky-nav-element-details">
<% article.decorate.cached_tag_list_array.each do |tag| %>
<span>#<%= tag %></span>
<% end %>
</div>
</a>
</div>
<% end %>
<% end %>
</div>

View file

@ -1,4 +1,4 @@
<% title "Edit Article - DEV" %>
<% title "Edit Post" %>
<% if @article.video.present? %>
<div style="text-align:center;padding-top:60px;margin:auto;max-width:880px;margin-bottom:-50px;">
@ -9,7 +9,7 @@
<div style="max-width:500px;margin:18px auto 0px;font-size:0.8em;">
<p>
<strong>You'll get an email when this process finishes.</strong>
</p>
</p>
<p>
<em>This is an ordinary unpublished post. Add title/tags/body/etc, and then mark as published after the video is done processing.</em>
</p>

View file

@ -216,7 +216,7 @@
</div>
</div>
<% cache("sticky-sidebar-#{@article.id}-#{@article.edited_at}", :expires_in => 8.hours) do %>
<% cache("sticky-sidebar-#{@article.id}-#{@article.edited_at}-#{(@organization || @user).updated_at}", :expires_in => 8.hours) do %>
<%= render "articles/sticky_nav" %>
<% end %>
@ -238,4 +238,4 @@
<%= GistTag.script.html_safe %>
<%= RunkitTag.script.html_safe %>
</script>
<% end %>
<% end %>

View file

@ -0,0 +1,26 @@
<% if @html_variant.errors.any? %>
<div id="error_explanation">
<h2><%= pluralize(@html_variant.errors.count, "error") %> prohibited this block from being saved:</h2>
<ul>
<% @html_variant.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<%= form_for(@html_variant) do |f| %>
<% if @html_variant.new_record? %>
<%= f.label :name %>
<%= f.text_field :name, placeholder: 'Unique, descriptive name' %>
<% end %>
<%= f.label :html %>
<%= f.text_area :html, placeholder: 'HTML to be shown. Make sure all CSS is properly scoped, and all HTML tags are closed, etc. Manditory field.' %>
<%= f.label :published %>
<%= f.check_box :published %>
<%= f.label :target_tag %>
<%= f.text_field :target_tag, placeholder: 'One tag, e.g. javascript. Optional targeting.' %>
<%= f.submit %>
<% end %>

View file

@ -0,0 +1,71 @@
<style>
.html-variants-page {
margin: 100px auto;
width: 800px;
max-width: 96%;
}
h1 a {
border: 1px solid black;
padding: 10px 40px;
border-radius: 100px;
}
input, textarea {
width: 100%;
font-size: 20px;
font-family: monospace;
padding: 10px;
margin-bottom: 20px;
}
input[type="submit"] {
background: blue;
color: white;
font-size: 30px;
padding: 15px 0px;
border: 0px;
}
textarea {
height: calc(100vh - 500px);
}
#error_explanation {
margin-bottom: 20px;
}
.html-variants-page-single-variant {
border: 1px solid black;
padding: 15px 15px 5px;
border-radius: 3px;
margin-bottom: 15px;
}
.html-variants-page-single-variant h3 {
margin-top: 0px;
}
.html-variants-page-single-variant-details {
margin: 5px 0px 0px;
padding: 10px 0px;
border-top: 1px solid gray;
}
.pill {
padding: 3px 10px;
display: inline-block;
margin-right: 5px;
border-radius: 100px;
background: gray;
color: white;
}
.published {
background: blue;
}
.approved {
background: green;
}
.html-variants-page-single-variant-details input {
background: green;
padding: 20px;
font-size:35px;
border-radius: 3px;
border: 0px;
margin-top: 15px;
color: white;
font-weight: bold;
}
</style>

View file

@ -0,0 +1,23 @@
<div class="html-variants-page-single-variant">
<h3>
<%= html_variant.name %>
</h3>
<%= html_variant.html %>
<div class="html-variants-page-single-variant-details">
<% if html_variant.published %>
<span class='pill published'>published</span>
<a class='pill' href="/html_variants/new?fork_id=<%= html_variant.id %>">fork</a>
<% else %>
<a class='pill' href="/html_variants/<%= html_variant.id %>/edit">edit</a>
<% end %>
<a class='pill' href="<%= @preview_path %>?html_variant_id=<%= html_variant.id %>&state=<%= html_variant.updated_at.to_i %>" target="_blank">preview</a>
<% if html_variant.approved %>
<span class='pill approved'>approved</span>
<% elsif admin %>
<%= form_for(html_variant) do |f| %>
<%= f.hidden_field :approved, value: "true" %>
<%= f.submit "APPROVE" %>
<% end %>
<% end %>
</div>
</div>

View file

@ -0,0 +1,6 @@
<%= render "page_styles" %>
<div class="html-variants-page" >
<h1><%= @html_variant.name %></h1>
<%= render "form" %>
</div>

View file

@ -0,0 +1,15 @@
<%= render "page_styles" %>
<div class="html-variants-page" >
<h1>
HTML Variants <a href="/html_variants/new">New</a>
</h1>
<h2>Your HTML Variants</h2>
<% @user_variants.each do |html_variant| %>
<%= render "single_html_variant", html_variant: html_variant, admin: false %>
<% end %>
<h2>All Published</h2>
<% @all_published_variants.each do |html_variant| %>
<%= render "single_html_variant", html_variant: html_variant, admin: true %>
<% end %>
</div>

View file

@ -0,0 +1,5 @@
<%= render "page_styles" %>
<div class="html-variants-page" >
<%= render "form" %>
</div>

View file

@ -113,38 +113,19 @@
<%= f.text_area :tech_stack, maxlength: 640, placeholder: "Limit of 640 characters" %>
</div>
<% if @organization.approved %>
<h2>Call-to-action box</h2>
<h2>Post Call-to-action box</h2>
<div class="field">
<%= f.label "Button Text" %>
<%= f.label "CTA Link Text" %>
<%= f.text_field :cta_button_text, maxlength: 12, placeholder: "Limit of 12 characters" %>
</div>
<div class="field">
<%= f.label "Button Link" %>
<%= f.label "CTA Link URL" %>
<%= f.text_field :cta_button_url %>
</div>
<div class="field">
<%= f.label "Body text" %>
<%= f.text_area :cta_body_markdown, maxlength: 140, placeholder: "Limit of 140 characters" %>
<%= f.label "Body text (markdown inline only)" %>
<%= f.text_area :cta_body_markdown, maxlength: 256, placeholder: "Limit of 256 characters" %>
</div>
<% if @organization.approved_and_filled_out_cta? %>
<div class="field">
Preview of Widget:
<div class="org-widget-preview">
<div class="primary-sticky-nav">
<div class="primary-sticky-nav-element primary-sticky-nav-author primary-sticky-nav-org">
<div class="primary-sticky-nav-org-summary">
<!-- Markdown -->
<%# @organization.cta_body_markdown %>
<%= @organization.cta_processed_html.html_safe %>
<button class="cta primary-sticky-nav-org-button">
<%= @organization.cta_button_text.upcase || "LEARN MORE" %>
</button>
</div>
</div>
</div>
</div>
</div>
<% end %>
<% end %>
<div class="field">
<label></label>

View file

@ -113,6 +113,9 @@ Rails.application.routes.draw do
resources :videos, only: %i[create new]
resources :video_states, only: [:create]
resources :twilio_tokens, only: [:show]
resources :html_variants
resources :html_variant_trials, only: [:create]
resources :html_variant_successes, only: [:create]
resources :push_notification_subscriptions, only: [:create]
get "/notifications/:username" => "notifications#index"

View file

@ -0,0 +1,15 @@
class CreateHtmlVariants < ActiveRecord::Migration[5.1]
def change
create_table :html_variants do |t|
t.integer :user_id
t.string :group
t.string :name
t.text :html
t.string :target_tag
t.float :success_rate, default: 0.0
t.boolean :published, default: false
t.boolean :approved, default: false
t.timestamps
end
end
end

View file

@ -0,0 +1,10 @@
class CreateHtmlVariantTrials < ActiveRecord::Migration[5.1]
def change
create_table :html_variant_trials do |t|
t.integer :html_variant_id
t.integer :article_id
t.timestamps
end
add_index :html_variant_trials, [:html_variant_id, :article_id]
end
end

View file

@ -0,0 +1,10 @@
class CreateHtmlVariantSuccesses < ActiveRecord::Migration[5.1]
def change
create_table :html_variant_successes do |t|
t.integer :html_variant_id
t.integer :article_id
t.timestamps
end
add_index :html_variant_successes, [:html_variant_id, :article_id]
end
end

View file

@ -1,5 +1,3 @@
# frozen_string_literal: true
# This file is auto-generated from the current state of the database. Instead
# of editing this file, please use the migrations feature of Active Record to
# incrementally modify your database, and then regenerate this schema definition.
@ -12,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 20181016181008) do
ActiveRecord::Schema.define(version: 20181020195954) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@ -358,6 +356,35 @@ ActiveRecord::Schema.define(version: 20181016181008) do
t.integer "watchers_count"
end
create_table "html_variant_successes", force: :cascade do |t|
t.integer "article_id"
t.datetime "created_at", null: false
t.integer "html_variant_id"
t.datetime "updated_at", null: false
t.index ["html_variant_id", "article_id"], name: "index_html_variant_successes_on_html_variant_id_and_article_id"
end
create_table "html_variant_trials", force: :cascade do |t|
t.integer "article_id"
t.datetime "created_at", null: false
t.integer "html_variant_id"
t.datetime "updated_at", null: false
t.index ["html_variant_id", "article_id"], name: "index_html_variant_trials_on_html_variant_id_and_article_id"
end
create_table "html_variants", force: :cascade do |t|
t.boolean "approved", default: false
t.datetime "created_at", null: false
t.string "group"
t.text "html"
t.string "name"
t.boolean "published", default: false
t.float "success_rate", default: 0.0
t.string "target_tag"
t.datetime "updated_at", null: false
t.integer "user_id"
end
create_table "identities", id: :serial, force: :cascade do |t|
t.text "auth_data_dump"
t.datetime "created_at", null: false

View file

@ -91,3 +91,12 @@ task :award_contributor_badges, [:arg1] => :environment do |_t, args|
BadgeRewarder.award_contributor_badges(usernames)
puts "Done!"
end
task remove_old_html_variant_data: :environment do
HtmlVariantTrial.where("created_at < ?", 1.week.ago).destroy_all
HtmlVariantSuccess.where("created_at < ?", 1.week.ago).destroy_all
HtmlVariant.find_each do |html_variant|
return if html_variant.html_variant_successes.size < 20
html_variant.calculate_success_rate!
end
end

View file

@ -0,0 +1,9 @@
FactoryBot.define do
factory :html_variant do
user
name { Faker::Hipster.paragraph(1) }
html { "<div>#{rand(10000000000)}</div><h1>HEllo</h1>" }
success_rate { 0.3 }
group { "article_show_sidebar_cta" }
end
end

View file

@ -0,0 +1,38 @@
require 'rails_helper'
RSpec.describe HtmlVariant, type: :model do
it { is_expected.to validate_uniqueness_of(:name) }
it { is_expected.to validate_presence_of(:html) }
it { is_expected.to belong_to(:user) }
let(:html_variant) { create(:html_variant, approved: true, published: true) }
it "calculates success rate" do
HtmlVariantTrial.create!(html_variant_id: html_variant.id)
HtmlVariantTrial.create!(html_variant_id: html_variant.id)
HtmlVariantTrial.create!(html_variant_id: html_variant.id)
HtmlVariantTrial.create!(html_variant_id: html_variant.id)
HtmlVariantSuccess.create!(html_variant_id: html_variant.id)
html_variant.calculate_success_rate!
expect(html_variant.success_rate).to eq(0.025)
end
it "finds for test without tag" do
html_variant.save!
expect(HtmlVariant.find_for_test.id).to eq(html_variant.id)
end
it "finds for test with tag given" do
html_variant.target_tag = "hello"
html_variant.save!
expect(HtmlVariant.find_for_test(["hello"]).id).to eq(html_variant.id)
end
it "does not find if different tag targeted" do
html_variant.target_tag = "different_tag_yolo"
html_variant.save!
expect(HtmlVariant.find_for_test(["hello"])).to eq(nil)
end
it "finds if no tag targeted and tag given" do
html_variant.save!
expect(HtmlVariant.find_for_test(["hello"]).id).to eq(html_variant.id)
end
end

View file

@ -0,0 +1,5 @@
require 'rails_helper'
RSpec.describe HtmlVariantSuccess, type: :model do
pending "add some examples to (or delete) #{__FILE__}"
end

View file

@ -0,0 +1,5 @@
require 'rails_helper'
RSpec.describe HtmlVariantTrial, type: :model do
pending "add some examples to (or delete) #{__FILE__}"
end

View file

@ -0,0 +1,18 @@
require "rails_helper"
RSpec.describe HtmlVariantPolicy do
subject { described_class.new(user, html_variant) }
context "when user is not an admin" do
let(:user) { build(:user) }
let(:html_variant) { build(:html_variant) }
it { is_expected.to forbid_actions(%i[index show edit update create]) }
end
context "when user is an admin" do
let(:user) { build(:user, :super_admin) }
let(:html_variant) { build(:html_variant) }
it { is_expected.to permit_actions(%i[index show edit update create]) }
end
end

View file

@ -0,0 +1,25 @@
require "rails_helper"
RSpec.describe "HtmlVariantSuccesses", type: :request do
let(:user) { create(:user) }
let(:article) { create(:article, user_id: user.id, approved: true) }
let(:html_variant) { create(:html_variant) }
before do
Delayed::Worker.delay_jobs = false
end
after do
Delayed::Worker.delay_jobs = true
end
describe "POST /html_variant_successes" do
it "rejects non-permissioned user" do
post "/html_variant_successes", params: {
article_id: article.id,
html_variant_id: html_variant.id,
}
expect(HtmlVariantSuccess.all.size).to eq(1)
end
end
end

View file

@ -0,0 +1,25 @@
require "rails_helper"
RSpec.describe "HtmlVariantTrials", type: :request do
let(:user) { create(:user) }
let(:article) { create(:article, user_id: user.id, approved: true) }
let(:html_variant) { create(:html_variant) }
before do
Delayed::Worker.delay_jobs = false
end
after do
Delayed::Worker.delay_jobs = true
end
describe "POST /html_variant_trials" do
it "rejects non-permissioned user" do
post "/html_variant_trials", params: {
article_id: article.id,
html_variant_id: html_variant.id,
}
expect(HtmlVariantTrial.all.size).to eq(1)
end
end
end

View file

@ -0,0 +1,110 @@
require "rails_helper"
RSpec.describe "HtmlVariants", type: :request do
let(:user) { create(:user)}
let(:article) { create(:article, user_id: user.id, approved: true) }
before do
sign_in user
end
describe "GET /html_variants" do
it "rejects non-permissioned user" do
expect { get "/html_variants" }.to raise_error(Pundit::NotAuthorizedError)
end
it "accepts permissioned" do
user.add_role(:super_admin)
get "/html_variants"
expect(response.body).to include("HTML Variants")
end
end
describe "GET /html_variants/new" do
it "rejects non-permissioned user" do
expect { get "/html_variants/new" }.to raise_error(Pundit::NotAuthorizedError)
end
it "accepts permissioned" do
user.add_role(:super_admin)
get "/html_variants/new"
expect(response.body).to include("<form")
end
end
describe "GET /html_variants/:id/edit" do
it "rejects non-permissioned user" do
html_variant = create(:html_variant)
expect { get "/html_variants/#{html_variant.id}/edit" }.to raise_error(Pundit::NotAuthorizedError)
end
it "accepts permissioned" do
user.add_role(:super_admin)
html_variant = create(:html_variant)
get "/html_variants/#{html_variant.id}/edit"
expect(response.body).to include("<form")
end
end
describe "Post /html_variants" do
it "rejects non-permissioned user" do
expect { post "/html_variants" }.to raise_error(Pundit::NotAuthorizedError)
end
it "creates" do
user.add_role(:super_admin)
post "/html_variants", params: {
html_variant: {
name: "New post",
html: "Yo ho ho#{rand(100)}", tag_list: "yoyo",
published: true
}
}
expect(HtmlVariant.all.size).to eq(1)
end
it "does not create with invalid params" do
user.add_role(:super_admin)
post "/html_variants", params: {
html_variant: {
# name: NOTHING HERE
html: "Yo ho ho#{rand(100)}", tag_list: "yoyo",
published: true
}
}
expect(HtmlVariant.all.size).to eq(1)
end
end
describe "Put /html_variants" do
it "rejects non-permissioned user" do
expect { post "/html_variants" }.to raise_error(Pundit::NotAuthorizedError)
end
it "updates when appropriate" do
user.add_role(:super_admin)
html_variant = create(:html_variant)
new_html = "Yo ho ho#{rand(100)}"
put "/html_variants/#{html_variant.id}", params: {
html_variant: {
html: new_html
}
}
expect(html_variant.reload.html).to eq(new_html)
end
it "does not create with invalid params" do
user.add_role(:super_admin)
html_variant = create(:html_variant, approved: true, published: true)
new_html = "Yo ho ho#{rand(100)}"
put "/html_variants/#{html_variant.id}", params: {
html_variant: {
html: new_html
}
}
expect(html_variant.reload.html).not_to eq(new_html)
end
end
end

View file

@ -4,7 +4,7 @@ RSpec.describe "StoriesShow", type: :request do
let(:user) { FactoryBot.create(:user) }
let(:article) { FactoryBot.create(:article, user_id: user.id) }
describe "GET /user" do
describe "GET /:username/:slug" do
it "renders to appropriate page" do
get article.path
expect(response.body).to include CGI.escapeHTML(article.title)
@ -66,4 +66,23 @@ RSpec.describe "StoriesShow", type: :request do
end
# rubocop:enable RSpec/ExampleLength
end
it "renders html variant" do
html_variant = create(:html_variant, published: true, approved: true)
get article.path
expect(response.body).to include html_variant.html
end
it "Does not render variant when no variants published" do
html_variant = create(:html_variant, published: false, approved: true)
get article.path
expect(response.body).not_to include html_variant.html
end
it "does not render html variant when user logged in" do
html_variant = create(:html_variant, published: true, approved: true)
sign_in user
get article.path
expect(response.body).not_to include html_variant.html
end
end