Add StimulusJS and Refactor Internal article_script (#5539) deploy]

* Add StimulusJS 1.1.1

Because:
- Internal will likely need more interactivity going forward
- Vanilla JS and jQuery are great, but aren't quite as organized as we
might like
- Stimulus fits our model of what internal should be (imo)
- Adopting Preact or something with templating would require rewriting a
bunch of erb (that is currently working just fine)

Stimulus is a JavaScript framework that directly compliments turbolinks
and the approach vanilla Rails takes to JavaScript. I've set it up to
specifically be used in the internal views where we are currently using
jQuery and vanilla JS. I think we should chip away at the vanilla JS
currently in place with this, which appears to be both scalable and
pragmatic exactly the sort of tool we want for building our internal
tools.

* Replace crufty article JS with Stimulus

I'm swapping out some of the stuff going on in article_script.erb with
some StimulusJS, it seems like a good test case for Stimulus.

This lets us organize the code a little more intentionally and hopefully
fixes a small performance complaint that Michael had.

* Rename uncommunicative methods

* Add unit tests for Stimulus classes

* Move Stimulus code to javascript/internal
This commit is contained in:
Jacob Herrington 2020-01-24 15:00:24 -06:00 committed by Ben Halpern
parent b980481d0d
commit 3e99fb527f
14 changed files with 270 additions and 70 deletions

View file

@ -0,0 +1,23 @@
// Sourced from https://github.com/stimulusjs/stimulus/issues/34
// and https://shime.sh/testing-stimulus
// Setup MutationObserver shim since jsdom doesn't
// support it out of the box.
import { readFileSync } from 'fs';
import { resolve } from 'path';
const shim = readFileSync(
resolve(
'node_modules',
'mutationobserver-shim',
'dist',
'mutationobserver.min.js',
),
{ encoding: 'utf-8' },
);
const script = window.document.createElement('script');
script.textContent = shim;
window.document.body.appendChild(script);

View file

@ -0,0 +1,61 @@
import { Application } from 'stimulus';
import ArticleController from '../../controllers/article_controller';
import '../../__mocks__/mutationObserver';
describe('ArticleController', () => {
beforeEach(() => {
document.body.innerHTML = `<div data-controller="article">
<button data-action="article#increaseFeaturedNumber"></button>
<button data-action="article#decreaseFeaturedNumber"></button>
<button data-action="article#highlightElement"></button>
<input data-target="article.featuredNumber"></input>
</div>`;
const application = Application.start();
application.register('article', ArticleController);
});
// Unix timestamp, one hour ago
const initialValue = Math.round((Date.now() - 3600000) / 1000);
describe('#increaseFeaturedNumber', () => {
it('increases the featured number input', () => {
const button = document.querySelectorAll('button')[0];
const input = document.querySelector(
"[data-target='article.featuredNumber']",
);
input.value = initialValue;
button.click();
expect(parseInt(input.value, 10)).toBeGreaterThan(initialValue);
});
});
describe('#decreaseFeaturedNumber', () => {
it('increases the featured number input', () => {
const button = document.querySelectorAll('button')[1];
const input = document.querySelector(
"[data-target='article.featuredNumber']",
);
input.value = initialValue;
button.click();
expect(parseInt(input.value, 10)).toBeLessThan(initialValue);
});
});
describe('#highlightElement', () => {
it('adds a class to the controller element', () => {
const button = document.querySelectorAll('button')[2];
const element = document.querySelector("[data-controller='article']");
button.click();
expect(
element.classList.contains('highlighted-bg', 'highlighted-border'),
).toBe(true);
});
});
});

View file

@ -0,0 +1,52 @@
import { Application } from 'stimulus';
import BufferController from '../../controllers/buffer_controller';
import '../../__mocks__/mutationObserver';
describe('BufferController', () => {
beforeEach(() => {
document.body.innerHTML = `<div data-controller="buffer">
<h2 data-target="buffer.header"></h2>
<button data-action="buffer#tagBufferUpdateConfirmed"></button>
<button data-action="buffer#tagBufferUpdateDismissed"></button>
<button data-action="buffer#highlightElement"></button>
</div>`;
const application = Application.start();
application.register('buffer', BufferController);
});
describe('#tagBufferUpdateConfirmed', () => {
it('adds a badge to the header', () => {
const button = document.querySelectorAll('button')[0];
const header = document.querySelector('h2');
button.click();
expect(header.firstChild.textContent).toMatch(/Confirm/);
});
});
describe('#tagBufferUpdateDismissed', () => {
it('adds a badge to the header', () => {
const button = document.querySelectorAll('button')[1];
const header = document.querySelector('h2');
button.click();
expect(header.firstChild.textContent).toMatch(/Dismiss/);
});
});
describe('#highlightElement', () => {
it('adds a class to the controller element', () => {
const button = document.querySelectorAll('button')[2];
const element = document.querySelector("[data-controller='buffer']");
button.click();
expect(
element.classList.contains('highlighted-bg', 'highlighted-border'),
).toBe(true);
});
});
});

View file

@ -0,0 +1,32 @@
import { Controller } from 'stimulus';
export default class ArticleController extends Controller {
static targets = ['featuredNumber'];
increaseFeaturedNumber() {
// Increases the article's chances of being seen
const seconds = new Date().getTime() / 1000;
this.featuredNumberTarget.value = Math.round(seconds);
}
decreaseFeaturedNumber() {
// Decreases the article's chances of being seen
const seconds = new Date().getTime() / 1080;
this.featuredNumberTarget.value = Math.round(seconds);
}
highlightElement() {
this.element.classList.add('highlighted-bg', 'highlighted-border');
setTimeout(() => {
this.element.classList.remove('highlighted-bg');
}, 350);
}
get articleId() {
return parseInt(this.data.get('id'), 10);
}
set articleId(value) {
this.data.set('id', value);
}
}

View file

@ -0,0 +1,30 @@
import { Controller } from 'stimulus';
export default class BufferController extends Controller {
static targets = ['header'];
tagBufferUpdateConfirmed() {
this.headerTarget.innerHTML +=
'<span class="ml-2 badge badge-success">Confirm</span>';
}
tagBufferUpdateDismissed() {
this.headerTarget.innerHTML +=
'<span class="ml-2 badge badge-danger">Dismiss</span>';
}
highlightElement() {
this.element.classList.add('highlighted-bg', 'highlighted-border');
setTimeout(() => {
this.element.classList.remove('highlighted-bg');
}, 350);
}
get bufferUpdateId() {
return parseInt(this.data.get('id'), 10);
}
set bufferUpdateId(value) {
this.data.set('id', value);
}
}

View file

@ -0,0 +1,6 @@
import { Application } from 'stimulus';
import { definitionsFromContext } from 'stimulus/webpack-helpers';
const application = Application.start();
const context = require.context('internal/controllers', true, /.js$/);
application.load(definitionsFromContext(context));

View file

@ -1,50 +1,5 @@
<script>
function timeNow(objectID) {
var seconds = new Date().getTime() / 1000;
document.getElementById("featured_number_" + objectID).value = Math.round(seconds);
}
function sink(objectID) {
var seconds = new Date().getTime() / 1080;
document.getElementById("featured_number_" + objectID).value = Math.round(seconds);
}
window.addEventListener('load', function() {
function submitForms() {
var form = $(this);
var valuesToSubmit = $(this).serialize();
$.ajax({
type: "POST",
url: $(this).attr('action'), //sumbits it to the given url of the form
data: valuesToSubmit,
}).success(function (json) {
form.parents(".card").addClass("highlighted-bg")
form.parents(".card").addClass("highlighted-border")
form.parents(".single-internal-listing").addClass("highlighted-bg")
form.parents(".single-internal-listing").addClass("highlighted-border")
setTimeout(function () {
form.parents(".card").removeClass("highlighted-bg")
form.parents(".single-internal-listing").removeClass("highlighted-bg")
}, 350)
});
return false; // prevents normal behaviour
}
$('.card').on("submit", "form", submitForms)
$('.buffer-area').on("submit", "form", submitForms)
function tagSuggestedTweet(event) {
var updateId = $(this).data("id");
var updateElement = $(`#suggested-tweet-${updateId}`)
if (event.target.classList.contains('buffer-confirm')) {
updateElement.find('.card-header h2').append('<span class="ml-2 badge badge-success">Confirm</span>')
} else if (event.target.classList.contains('buffer-dismiss')) {
updateElement.find('.card-header h2').append('<span class="ml-2 badge badge-danger">Dismiss</span>')
}
}
$('.buffer-form').on("submit", tagSuggestedTweet);
imageUploadButt = document.getElementById('image-upload-button')
imageUpload = document.getElementById('image-upload')
imageUploadSubmit = document.getElementById('image-upload-submit')

View file

@ -1,4 +1,4 @@
<div class="card my-3">
<div class="card my-3" data-controller="article" data-article-id="<%= article.id %>">
<div class="card-header">
<a href="<%= article.path %>" target="_blank"><%= article.title %></a>
<a class="ml-2" href="<%= article.path %>/edit" target="_blank"><span class="badge badge-success">EDIT</span></a>
@ -68,11 +68,10 @@
</li>
<% end %>
</ul>
<button class="btn btn-success" data-action="article#increaseFeaturedNumber">☝️ Boost!</button>
<button class="btn btn-danger" data-action="article#decreaseFeaturedNumber">👇 SPAM!</button>
<button class="btn btn-success" onclick="timeNow(<%= article.id %>);return false;">☝️ Boost!</button>
<button class="btn btn-danger" onclick="sink(<%= article.id %>);return false;">👇 SPAM!</button>
<form action="/internal/articles/<%= article.id %>" accept-charset="UTF-8" method="post">
<%= form_with url: internal_article_path(article.id), html: { data: { action: "submit->article#highlightElement" } } do |f| %>
<div class="form-group">
<input name="utf8" type="hidden" value="✓">
<input type="hidden" name="authenticity_token" value="<%= form_authenticity_token %>" />
@ -80,7 +79,7 @@
</div>
<div class="form-group">
<label for="featured_number_<%= article.id %>">Featured Number:</label>
<input id="featured_number_<%= article.id %>" class="form-control" name="article[featured_number]" value="<%= article.featured_number %>">
<input id="featured_number_<%= article.id %>" class="form-control" name="article[featured_number]" value="<%= article.featured_number %>" data-target="article.featuredNumber">
</div>
<div class="form-group">
<label for="author_id_<%= article.id %>">Author ID:</label>
@ -131,7 +130,7 @@
<% end %>
<button class="btn btn-primary float-right">Submit</button>
</form>
<% end %>
<% if article.last_buffered %>
<div class="mt-5">
@ -179,7 +178,7 @@
</div>
<div>
<%= form_tag "/internal/buffer_updates" do %>
<%= form_with url: "/internal/buffer_updates", html: { data: { action: "submit->article#highlightElement" } } do %>
<input type="hidden" name="social_channel" value="main_twitter" />
<input type="hidden" name="article_id" value="<%= article.id %>" />
<h5>Twitter MAIN</h5>
@ -192,7 +191,7 @@
<% end %>
<% if (article.decorate.cached_tag_list_array & Tag.bufferized_tags).any? %>
<%= form_tag "/internal/buffer_updates" do %>
<%= form_with url: "/internal/buffer_updates", html: { data: { action: "submit->article#highlightElement" } } do %>
<input type="hidden" name="social_channel" value="satellite_twitter" />
<input type="hidden" name="article_id" value="<%= article.id %>" />
<h5>Twitter Satellite</h5>
@ -206,7 +205,7 @@
<% end %>
</div>
<%= form_tag "/internal/buffer_updates" do %>
<%= form_with url: "/internal/buffer_updates", html: { data: { action: "submit->article#highlightElement" } } do %>
<input type="hidden" name="social_channel" value="facebook" />
<input type="hidden" name="article_id" value="<%= article.id %>" />
<h5>Facebook & LinkedIn</h5>

View file

@ -1,5 +1,5 @@
<div class="row">
<div class="col-12 py-2 sticky-top bg-white d-flex justify-content-center editor-image-upload">
<div class="col-12 py-2 sticky-top bg-white d-flex justify-content-center editor-image-upload" data-controller="image-upload">
<input type="file" id="image-upload" name="file" accept="image/*" style="display:none">
<input type="submit" id='image-upload-submit' value="Upload" style="display:none">
<button class="btn btn-primary mr-2" id="image-upload-button">Upload Image</button>
@ -47,9 +47,9 @@
<summary style="font-size: 1.3em; cursor: pointer;">Suggested tweets (<%= @pending_buffer_updates.size %>)</summary>
<% @pending_buffer_updates.each do |buffer_update| %>
<% next unless buffer_update.article %>
<div class="card my-3" id="suggested-tweet-<%= buffer_update.id %>">
<div class="card my-3" id="suggested-tweet-<%= buffer_update.id %>" data-controller="buffer">
<div class="card-header">
<h2 class="my-0"><%= buffer_update.article.title %></h2>
<h2 class="my-0" data-target="buffer.header"><%= buffer_update.article.title %></h2>
</div>
<div class="card-body">
<h4>Score: <%= buffer_update.article.score %></h4>
@ -60,7 +60,7 @@
<code><b><%= Tag.find_by(id: buffer_update.tag_id)&.name || buffer_update.social_service_name %>:</b></code>
<form action="/internal/buffer_updates/<%= buffer_update.id %>" accept-charset="UTF-8" method="post" class="buffer-form buffer-confirm" data-id="<%= buffer_update.id %>">
<%= form_with url: "/internal/buffer_updates/#{buffer_update.id}", class: "buffer-form buffer-confirm", html: { data: { action: "submit->buffer#highlightElement" } } do |f| %>
<div class="form-group">
<input name="utf8" type="hidden" value="✓">
<input type="hidden" name="authenticity_token" value="<%= form_authenticity_token %>" />
@ -68,18 +68,18 @@
<input type="hidden" name="status" value="confirmed" />
<textarea name="body_text" class="form-control"><%= buffer_update.body_text %></textarea>
</div>
<button value="confirmed" name="status" class="btn btn-success">Confirm</button>
</form>
<button value="confirmed" name="status" class="btn btn-success" data-action="buffer#tagBufferUpdateConfirmed">Confirm</button>
<% end %>
<form action="/internal/buffer_updates/<%= buffer_update.id %>" accept-charset="UTF-8" method="post" class="buffer-form buffer-dismiss" data-id="<%= buffer_update.id %>">
<%= form_with url: "/internal/buffer_updates/#{buffer_update.id}", class: "buffer-form buffer-dismiss", html: { data: { action: "submit->buffer#highlightElement" } } do |f| %>
<div class="form-group">
<input name="utf8" type="hidden" value="✓">
<input type="hidden" name="authenticity_token" value="<%= form_authenticity_token %>" />
<input type="hidden" name="_method" value="patch" />
<input type="hidden" name="status" value="dismissed" />
</div>
<button value="dismissed" name="status" class="btn btn-danger">Dismiss</button>
</form>
<button value="dismissed" name="status" class="btn btn-danger" data-action="buffer#tagBufferUpdateDismissed">Dismiss</button>
<% end %>
</div>
</div>
<% end %>
@ -105,4 +105,4 @@
</div>
</div>
<%= render "article_script" %>
<%= render "image_upload_script" %>

View file

@ -1,2 +1 @@
<%= render "individual_article", article: @article %>
<%= render "article_script" %>

View file

@ -29,7 +29,7 @@
<div class="grid-item">Last Bumped</div>
</div>
<% @classified_listings.each do |listing| %>
<div class="single-internal-listing">
<div class="single-internal-listing" data-controller="buffer">
<div class="wrapper">
<div class="grid-item">
<a href="/internal/listings/<%= listing.id %>/edit" target="_blank">
@ -60,12 +60,12 @@
<p class="listing-body"><%= listing.processed_html&.html_safe %></p>
</div>
<div class="grid-item">
<%= form_tag "/internal/buffer_updates" do %>
<%= form_with url: "/internal/buffer_updates", html: { data: { action: "submit->buffer#highlightElement" } } do %>
<input type="hidden" name="social_channel" value="listings_twitter" />
<input type="hidden" name="listing_id" value="<%= listing.id %>" />
<textarea cols="37" rows="8" wrap="hard" name="tweet" maxlength="255">📋 New DEV Listing!&#013&#013Category: <%= listing.category %>&#013&#013<%= listing.title %>&#013&#013<% if listing.user.twitter_username? %>Posted by @<%= listing.user.twitter_username %><% end %></textarea>
<br>
<button class="btn btn-primary">🐦 Tweet 🐦</button>
<button class="btn btn-primary" data-action=>🐦 Tweet 🐦</button>
<% end %>
</div>
</div>
@ -75,4 +75,3 @@
<% end %>
<%= paginate @classified_listings %>
<%= render "internal/articles/article_script" %>

View file

@ -11,6 +11,11 @@
<%= favicon_link_tag SiteConfig.favicon_url %>
<title><%= controller_name.titleize %></title>
<!-- StimulusJS -->
<%= javascript_pack_tag "manifest", defer: true %>
<%= javascript_pack_tag "vendor", defer: true %>
<%= javascript_pack_tag "internal", defer: true %>
<script src="https://code.jquery.com/jquery-3.4.1.slim.min.js" integrity="sha384-J6qa4849blE2+poT4WnyKhv5vZF5SrPo0iEjwBvKU7imGFAV0wwj1yYfoRSJoZ+n" crossorigin="anonymous"></script>
<!-- Bootstrap and FontAwesome -->

View file

@ -79,6 +79,7 @@
"jest-fetch-mock": "^3.0.1",
"jsdom": "^15.2.1",
"lint-staged": "^10.0.0",
"mutationobserver-shim": "^0.3.3",
"preact-render-spy": "1.3.0",
"preact-render-to-json": "^3.6.6",
"prettier": "^1.19.1",
@ -105,6 +106,7 @@
"preact-textarea-autosize": "^4.0.7",
"prop-types": "^15.7.2",
"pusher-js": "^5.0.3",
"stimulus": "^1.1.1",
"twilio-video": "^2.0.0",
"web-share-wrapper": "^0.2.1"
},

View file

@ -290,6 +290,30 @@
dependencies:
any-observable "^0.3.0"
"@stimulus/core@^1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@stimulus/core/-/core-1.1.1.tgz#42b0cfe5b73ca492f41de64b77a03980bae92c82"
integrity sha512-PVJv7IpuQx0MVPCBblXc6O2zbCmU8dlxXNH4bC9KK6LsvGaE+PCXXrXQfXUwAsse1/CmRu/iQG7Ov58himjiGg==
dependencies:
"@stimulus/mutation-observers" "^1.1.1"
"@stimulus/multimap@^1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@stimulus/multimap/-/multimap-1.1.1.tgz#b95e3fd607345ab36e5d5b55486ee1a12d56b331"
integrity sha512-26R1fI3a8uUj0WlMmta4qcfIQGlagegdP4PTz6lz852q/dXlG6r+uPS/bx+H8GtfyS+OOXVr3SkZ0Zg0iRqRfQ==
"@stimulus/mutation-observers@^1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@stimulus/mutation-observers/-/mutation-observers-1.1.1.tgz#0f6c6f081308427fed2a26360dda0c173b79cfc0"
integrity sha512-/zCnnw1KJlWO2mrx0yxYaRFZWMGnDMdOgSnI4hxDLxdWVuL2HMROU8FpHWVBLjKY3T9A+lGkcrmPGDHF3pfS9w==
dependencies:
"@stimulus/multimap" "^1.1.1"
"@stimulus/webpack-helpers@^1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@stimulus/webpack-helpers/-/webpack-helpers-1.1.1.tgz#eff60cd4e58b921d1a2764dc5215f5141510f2c2"
integrity sha512-XOkqSw53N9072FLHvpLM25PIwy+ndkSSbnTtjKuyzsv8K5yfkFB2rv68jU1pzqYa9FZLcvZWP4yazC0V38dx9A==
"@storybook/addon-actions@3.4.12":
version "3.4.12"
resolved "https://registry.yarnpkg.com/@storybook/addon-actions/-/addon-actions-3.4.12.tgz#ff6cbaf563c3cb5d648d6a35f66cfa50ced49bf4"
@ -8297,6 +8321,11 @@ multicast-dns@^6.0.1:
dns-packet "^1.3.1"
thunky "^1.0.2"
mutationobserver-shim@^0.3.3:
version "0.3.3"
resolved "https://registry.yarnpkg.com/mutationobserver-shim/-/mutationobserver-shim-0.3.3.tgz#65869630bc89d7bf8c9cd9cb82188cd955aacd2b"
integrity sha512-gciOLNN8Vsf7YzcqRjKzlAJ6y7e+B86u7i3KXes0xfxx/nfLmozlW1Vn+Sc9x3tPIePFgc1AeIFhtRgkqTjzDQ==
mute-stream@0.0.7:
version "0.0.7"
resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.7.tgz#3075ce93bc21b8fab43e1bc4da7e8115ed1e7bab"
@ -12071,6 +12100,14 @@ stickyfill@^1.1.1:
resolved "https://registry.yarnpkg.com/stickyfill/-/stickyfill-1.1.1.tgz#39413fee9d025c74a7e59ceecb23784cc0f17f02"
integrity sha1-OUE/7p0CXHSn5ZzuyyN4TMDxfwI=
stimulus@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/stimulus/-/stimulus-1.1.1.tgz#53c2fded6849e7b85eed3ed8dd76e33abd74bec5"
integrity sha512-R0mBqKp48YnRDZOxZ8hiOH4Ilph3Yj78CIFTBkCwyHs4iGCpe7xlEdQ7cjIxb+7qVCSxFKgxO+mAQbsNgt/5XQ==
dependencies:
"@stimulus/core" "^1.1.1"
"@stimulus/webpack-helpers" "^1.1.1"
store2@^2.7.1:
version "2.7.1"
resolved "https://registry.yarnpkg.com/store2/-/store2-2.7.1.tgz#22070b7dc04748a792fc6912a58ab99d3a21d788"