Pro: History (#3220)

* Add base history support

* Add tags and ranking and make it work

* Fix styling

* Add article title and text excerpt to the index

* Add pagination support

* Debounce the search to 300ms

* Show history only to pro users

* Use routing helpers instead of hardcoding

* Raise the page size to 100

* Remove console log

* A bit of regrouping
This commit is contained in:
rhymes 2019-06-18 20:58:24 +02:00 committed by Ben Halpern
parent 9e616de9f8
commit 2bf54edb9c
19 changed files with 682 additions and 35 deletions

View file

@ -63,12 +63,15 @@ function handleFormSubmit(e) {
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.send(data);
xhr.onload = function() {
xhr.onload = function onload() {
var article = form.closest('div.single-article');
if (xhr.status === 200) {
onXhrSuccess(form, article, values);
var message = values.commit === 'Mute Notifications' ? 'Notifications Muted' : 'Notifications Restored';
var message =
values.commit === 'Mute Notifications'
? 'Notifications Muted'
: 'Notifications Restored';
article.querySelector('.dashboard-meta-details').innerHTML = message;
} else {
article.querySelector('.dashboard-meta-details').innerHTML =
@ -141,9 +144,10 @@ function initializeEllipsisMenuToggle() {
}
// Hide ellipsis menus when you click outside of the ellipsis menu parent div
document
.getElementsByTagName('BODY')[0]
.addEventListener('click', hideEllipsisMenus);
const body = document.getElementsByTagName('body')[0];
if (body) {
body.addEventListener('click', hideEllipsisMenus);
}
}
function initializeEllipsisMenu() {

View file

@ -1,3 +1,8 @@
function checkUserLoggedIn() {
return document.getElementsByTagName('body')[0].getAttribute('data-user-status') == "logged-in"
}
const body = document.getElementsByTagName('body')[0];
if (!body) {
return false;
}
return body.getAttribute('data-user-status') === 'logged-in';
}

View file

@ -0,0 +1,237 @@
@import 'variables';
@import 'mixins';
.history-container {
@media screen and (min-width: 950px) {
margin-top: -25px;
}
a {
display: block;
}
.history-filters {
width: 300px;
float: left;
min-height: 356px;
position: relative;
input {
width: 100%;
border: 0px;
padding: 5px;
@include themeable(
border,
theme-container-border,
1px solid $light-medium-gray
);
border-radius: 3px;
font-size: 1em;
box-sizing: border-box;
margin-bottom: 4px;
@include themeable(
background,
theme-container-accent-background,
lighten($light-gray, 2%)
);
@include themeable(color, theme-color, $black);
}
.history-tags {
padding-bottom: 25px;
}
.history-tag {
padding: 5px 0px;
position: relative;
padding-left: 5px;
@include themeable(color, theme-color, $black);
&:hover {
@include themeable(
background,
theme-container-background-hover,
$light-gray
);
}
&.selected {
background: $purple;
}
}
.history-view-toggle {
position: absolute;
bottom: 10px;
left: 15px;
font-size: 0.8em;
}
}
.history-results {
width: calc(100% - 20px);
margin: 10px auto;
margin-left: 10px;
float: left;
@include themeable(background, theme-container-background, white);
@include themeable(
border,
theme-container-border,
1px solid $light-medium-gray
);
border-radius: 3px;
min-height: 350px;
display: none;
text-align: left;
font-size: 14px;
@media screen and (min-width: 950px) {
width: calc(100% - 332px);
margin-left: 20px;
margin-top: 0px;
font-size: 17px;
}
&.history-results--loaded {
display: block;
}
.history-results-header {
@include themeable(
background,
theme-container-accent-background,
darken($light-gray, 2%)
);
padding: 17px;
font-size: 1.8em;
font-weight: bold;
}
.history-item-wrapper {
position: relative;
.history-archive-butt {
position: absolute;
bottom: 6px;
right: 5px;
font-size: 11px;
font-weight: bold;
border-radius: 3px;
background: transparent;
border: 1px solid $black;
@include themeable(color, theme-secondary-color, $medium-gray);
@include themeable(border-color, theme-secondary-color, $medium-gray);
}
}
.history-item {
@include themeable(color, theme-color, $black);
font-size: 0.8em;
padding: 17px 17px 25px;
@include themeable(
border-bottom,
theme-container-border,
1px solid $light-medium-gray
);
.history-item-title {
font-weight: bold;
font-size: 1.7em;
margin-bottom: 10px;
}
.history-item-details {
a {
display: inline-block;
@include themeable(color, theme-secondary-color, $medium-gray);
&.history-item-user {
margin-right: 5px;
img {
height: 22px;
width: 22px;
border-radius: 100px;
vertical-align: -5px;
margin-right: 5px;
}
}
.history-item-tag-collection {
display: inline-block;
}
&.history-item-tag {
margin-left: 1px;
padding: 1px 2px;
border-radius: 3px;
}
}
}
&:hover {
@include themeable(
background,
theme-container-background-hover,
$light-gray
);
}
}
.history-empty {
text-align: center;
padding: 100px 0px;
span.highlight {
background: $bold-blue;
border-radius: 3px;
padding: 3px 8px;
margin: 0px 8px;
color: white;
display: inline-block;
span[role='img'] {
margin-left: 8px;
}
}
}
.history-results-load-more {
text-align: center;
button {
background: transparent;
@include themeable(border, theme-border, 1px solid $light-medium-gray);
font-size: 17px;
padding: 14px 5px;
margin: 30px auto;
width: 120px;
max-width: 80%;
border-radius: 100px;
font-weight: bold;
}
}
}
}
@media screen and (max-width: 949px) {
.history-home {
.side-bar {
display: block;
position: relative;
width: calc(100% - 15px);
margin: 10px auto;
margin-left: 10px;
margin-bottom: -15px;
.history-filters {
height: 85px;
min-height: 85px;
.history-tags {
height: 40px;
overflow: hidden;
width: 100%;
overflow-x: scroll;
overflow-y: hidden;
white-space: nowrap;
.history-tag {
display: inline-block;
padding: 1px 6px;
border-radius: 3px;
margin-right: 3px;
}
}
}
}
}
}

View file

@ -35,7 +35,9 @@ body {
}
.zen-mode {
.top-bar, .primary-sticky-nav, .article-actions {
.top-bar,
.primary-sticky-nav,
.article-actions {
display: none !important;
}
}
@ -57,6 +59,7 @@ body {
&.stories-search,
&.podcast_episodes-index,
&.reading_list_items-index,
&.history-index,
.tags-index,
&.twitch_live_streams-show {
margin-top: 68px;
@ -136,7 +139,11 @@ input[type='email'] {
bottom: 20px;
left: 20px;
right: 20px;
@include themeable(background, theme-container-accent-background, $light-purple);
@include themeable(
background,
theme-container-accent-background,
$light-purple
);
@include themeable(border, container-border, 2px solid $purple);
padding: 12px 30px;
z-index: 20;
@ -149,12 +156,19 @@ input[type='email'] {
-webkit-animation-name: slide-in-from-left;
-webkit-animation-duration: 0.25s;
}
}
@-webkit-keyframes slide-in-from-left {
0% { left: -300px;}
50%{ left : 0px;}
90%{ left : 24px;}
100%{ left: 20px;}
0% {
left: -300px;
}
50% {
left: 0px;
}
90% {
left: 24px;
}
100% {
left: 20px;
}
}

View file

@ -0,0 +1,16 @@
class HistoryController < ApplicationController
before_action :generate_algolia_search_key
def index
authorize current_user, :pro_user?
@history_index = true # used exclusively by the ERb templates
end
private
def generate_algolia_search_key
params = { filters: "viewable_by:#{current_user.id}" }
key = ApplicationConfig["ALGOLIASEARCH_SEARCH_ONLY_KEY"]
@secured_algolia_key = Algolia.generate_secured_api_key(key, params)
end
end

View file

@ -14,6 +14,8 @@ class ReadingListItemsController < ApplicationController
head :ok
end
private
def generate_algolia_search_key
params = { filters: "viewable_by:#{current_user.id}" }
@secured_algolia_key = Algolia.generate_secured_api_key(

View file

@ -0,0 +1,227 @@
import { h, Component } from 'preact';
import { PropTypes } from 'preact-compat';
import debounce from 'lodash.debounce';
import setupAlgoliaIndex from '../src/utils/algolia';
export class History extends Component {
constructor(props) {
super(props);
const { availableTags } = this.props;
this.state = {
query: '',
items: [],
totalCount: 0,
index: null,
itemsLoaded: false,
hitsPerPage: 100,
availableTags,
selectedTags: [],
page: 0,
showNextPageButton: false,
};
this.handleTyping = debounce(this.handleTyping.bind(this), 300, {
leading: true,
});
}
componentDidMount() {
const index = setupAlgoliaIndex({
containerId: 'history',
indexName: 'UserHistory',
});
// get default result set from Algolia
const { hitsPerPage } = this.state;
index.search('', { hitsPerPage }).then(content => {
this.setState({
items: content.hits,
totalCount: content.nbHits,
index,
itemsLoaded: true,
showNextPageButton: content.hits.length === hitsPerPage,
});
});
}
handleTyping = event => {
const query = event.target.value;
const { selectedTags } = this.state;
this.setState({ page: 0, items: [] });
this.search(query, { tags: selectedTags });
};
toggleTag = (event, tag) => {
event.preventDefault();
const { query, selectedTags } = this.state;
const newTags = selectedTags;
if (newTags.indexOf(tag) === -1) {
newTags.push(tag);
} else {
newTags.splice(newTags.indexOf(tag), 1);
}
this.setState({ selectedTags: newTags, page: 0, items: [] });
this.search(query, { tags: newTags });
};
loadNextPage = () => {
const { query, selectedTags, page } = this.state;
this.setState({ page: page + 1 });
this.search(query, { selectedTags });
};
search(query, { tags }) {
const { index, hitsPerPage, page, items } = this.state;
const filters = { hitsPerPage, page };
if (tags && tags.length > 0) {
filters.tagFilters = tags;
}
index.search(query, filters).then(content => {
// add new items to the bottom
const allItems = items;
const itemsIds = items.map(i => i.objectID);
content.hits.forEach(item => {
if (!itemsIds.includes(item.objectID)) {
allItems.push(item);
}
});
this.setState({
query,
items: allItems,
totalCount: content.nbHits,
showNextPageButton: content.hits.length === hitsPerPage,
});
});
}
renderNoItems() {
const { selectedTags, query } = this.state;
return (
<div className="history-empty">
<h1>
{selectedTags.length === 0 && query.length === 0
? 'Your History is Lonely'
: 'Nothing with this filter 🤔'}
</h1>
</div>
);
}
renderItems() {
const { items, itemsLoaded } = this.state;
if (items.length === 0 && itemsLoaded) {
return this.renderNoItems();
}
return items.map(item => (
<div className="history-item-wrapper">
<a className="history-item" href={item.article_path}>
<div className="history-item-title">{item.article_title}</div>
<div className="history-item-details">
<a
className="history-item-user"
href={`/${item.article_user.username}`}
>
<img src={item.article_user.profile_image_90} alt="Profile Pic" />
{item.article_user.name}
{item.article_reading_time}
{' '}
min read
{`visited on ${item.readable_visited_at}`}
</a>
<span className="history-item-tag-collection">
{item.article_tags.map(tag => (
<a className="history-item-tag" href={`/t/${tag}`}>
#
{tag}
</a>
))}
</span>
</div>
</a>
</div>
));
}
renderTags() {
const { availableTags, selectedTags } = this.state;
return availableTags.map(tag => (
<a
className={`history-tag ${
selectedTags.indexOf(tag) > -1 ? 'selected' : ''
}`}
href={`/t/${tag}`}
data-no-instant
onClick={e => this.toggleTag(e, tag)}
>
#
{tag}
</a>
));
}
renderNextPageButton() {
const { showNextPageButton } = this.state;
if (showNextPageButton) {
return (
<div className="history-results-load-more">
<button onClick={e => this.loadNextPage(e)} type="button">
Load More
</button>
</div>
);
}
return '';
}
render() {
const { itemsLoaded, totalCount } = this.state;
const allItems = this.renderItems();
const allTags = this.renderTags();
const nextPageButton = this.renderNextPageButton();
return (
<div className="home history-home">
<div className="side-bar">
<div className="widget history-filters">
<input onKeyUp={this.handleTyping} placeHolder="search your list" />
<div className="history-tags">{allTags}</div>
</div>
</div>
<div
className={`history-results ${
itemsLoaded ? 'history-results--loaded' : ''
}`}
>
<div className="history-results-header">
History
{' '}
{`(${totalCount})`}
</div>
<div>{allItems}</div>
{nextPageButton}
</div>
</div>
);
}
}
History.propTypes = {
availableTags: PropTypes.arrayOf(PropTypes.string).isRequired,
};

View file

@ -0,0 +1,22 @@
import { h, render } from 'preact';
import { getUserDataAndCsrfToken } from '../chat/util';
import { History } from '../history/history';
function loadComponent() {
getUserDataAndCsrfToken().then(({ currentUser }) => {
const root = document.getElementById('history');
if (root) {
render(
<History availableTags={currentUser.followed_tag_names} />,
root,
root.firstElementChild,
);
}
});
}
window.InstantClick.on('change', () => {
loadComponent();
});
loadComponent();

View file

@ -128,13 +128,19 @@ export class ReadingList extends Component {
src={item.reactable_user.profile_image_90}
alt="Profile Pic"
/>
{item.reactable_user.name}{item.reactable_published_date}
{item.reading_time} min read
{item.reactable_user.name}
{item.reactable_published_date}
{item.reading_time}
{' '}
min read
</a>
<span className="readinglist-item-tag-collection">
{item.reactable_tags.map(tag => (
<a className="readinglist-item-tag" href={`/t/${tag}`}>
#{tag}
#
{tag}
</a>
))}
</span>
@ -160,9 +166,9 @@ export class ReadingList extends Component {
</h1>
<h3>
Hit the
<span class="highlight">SAVE</span>
<span className="highlight">SAVE</span>
or
<span class="highlight">
<span className="highlight">
Bookmark
<span role="img" aria-label="Bookmark">
🔖
@ -193,7 +199,8 @@ export class ReadingList extends Component {
data-no-instant
onClick={e => this.toggleTag(e, tag)}
>
#{tag}
#
{tag}
</a>
));
const snackBar = archiving ? (

View file

@ -0,0 +1,7 @@
export default function setupAlgoliaIndex({ containerId, indexName }) {
const id = document.querySelector("meta[name='algolia-public-id']").content;
const key = document.getElementById(containerId).dataset.algoliaKey;
const env = document.querySelector("meta[name='environment']").content;
const client = algoliasearch(id, key);
return client.initIndex(`${indexName}_${env}`);
}

View file

@ -1,4 +1,61 @@
class PageView < ApplicationRecord
include AlgoliaSearch
belongs_to :user, optional: true
belongs_to :article
algoliasearch index_name: "UserHistory", per_environment: true, if: :belongs_to_pro_user? do
attributes :referrer, :time_tracked_in_seconds, :user_agent, :article_tags
attribute(:article_title) { article.title }
attribute(:article_path) { article.path }
attribute(:article_reading_time) { article.reading_time }
attribute(:viewable_by) { user_id }
attribute(:visited_at_timestamp) { created_at.to_i }
attribute :article_user do
user = article.user
{
username: user.username,
name: user.name,
profile_image_90: user.profile_image_90
}
end
attribute :readable_visited_at do
if created_at.year == Time.current.year
created_at.strftime("%b %e")
else
created_at.strftime("%b %e '%y")
end
end
searchableAttributes(
%i[referrer user_agent article_title article_searchable_tags article_searchable_text],
)
tags { article_tags }
attributesForFaceting ["filterOnly(viewable_by)"]
customRanking ["desc(visited_at_timestamp)"]
end
private
def belongs_to_pro_user?
user&.pro?
end
def article_searchable_tags
article.cached_tag_list
end
def article_searchable_text
article.body_text[0..350]
end
def article_tags
article.decorate.cached_tag_list_array
end
end

View file

@ -19,17 +19,17 @@
navigation
</div>
<div class="sidebar-nav-element">
<a class="sidebar-nav-link" href="/readinglist?we-hope-you-like-the-new-and-improved-reading-list---more-positive-changes-coming=woohoo">
<a class="sidebar-nav-link" href="<%= readinglist_path %>">
<img class="nav-emoji" src="<%= asset_path("emoji/emoji-one-bookmark.png") %>" alt="reading list icon" /> Reading List
<span id="reading-list-count"></span>
</a>
<a class="sidebar-nav-link" href="/listings">
<a class="sidebar-nav-link" href="<%= classified_listings_path %>">
<img class="nav-emoji" src="<%= asset_path("emoji/emoji-one-clipboard.png") %>" alt="listings icon" /> Listings
</a>
<a class="sidebar-nav-link" href="/videos">
<a class="sidebar-nav-link" href="<%= videos_path %>">
<img class="nav-emoji" src="<%= asset_path("emoji/emoji-one-videocamera.png") %>" alt="videos icon" /> Videos
</a>
<a class="sidebar-nav-link" href="/pod">
<a class="sidebar-nav-link" href="<%= pod_path %>">
<img class="nav-emoji" src="<%= asset_path("emoji/emoji-one-headphones.png") %>" alt="podcasts icon" /> Podcasts
</a>
</div>
@ -49,7 +49,7 @@
<% end %>
<% Tag.where(supported: true).order("hotness_score DESC").limit(30).pluck(:id, :name).each do |tag_array| %>
<div class="sidebar-nav-element" id="default-sidebar-element-<%= tag_array.second %>">
<a class="sidebar-nav-link" href="/t/<%= tag_array.second %>">
<a class="sidebar-nav-link" href="<%= tag_path(tag_array.second) %>">
<span class="sidebar-nav-tag-text">
#<%= tag_array.second %>
</span>
@ -64,7 +64,7 @@
</div>
</div>
<div class="sidebar-nav-subheader">
<a href="/tags">View all-time top tags</a>
<a href="<%= tags_path %>">View all-time top tags</a>
</div>
</div>
</div>

View file

@ -0,0 +1,26 @@
<% title "History" %>
<%= content_for :page_meta do %>
<% page_url = "#{ApplicationConfig["APP_PROTOCOL"]}#{ApplicationConfig["APP_DOMAIN"]}#{history_path}" %>
<link rel="canonical" href="<%= page_url %>" />
<meta name="description" content="Page views history for <%= community_qualified_name %>">
<meta name="keywords" content="software development,engineering,rails,javascript,ruby">
<meta property="og:type" content="article" />
<meta property="og:url" content="<%= page_url %>" />
<meta property="og:title" content="History - <%= community_qualified_name %>" />
<meta property="og:description" content="My Posts History on <%= community_qualified_name %>" />
<meta property="og:site_name" content="<%= community_qualified_name %>" />
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:site" content="@<%= ApplicationConfig["SITE_TWITTER_HANDLE"] %>">
<meta name="twitter:title" content="History - <%= community_qualified_name %>">
<meta name="twitter:description" content="Page views history for <%= community_qualified_name %>">
<% end %>
<div class="home">
<div class="history-container" id="history" data-algolia-key="<%= @secured_algolia_key %>">
</div>
</div>
<%= javascript_pack_tag "history", defer: true %>

View file

@ -2,9 +2,9 @@
<div id="footer-container" class="container <%= "centered-footer" unless current_page.include?("stories-show") %>">
<div class="inner-footer-container">
<a href="/">Home</a> <a href="/about">About</a> <a href="/privacy">Privacy Policy</a>
<a href="/terms">Terms of Use</a> <a href="/contact">Contact</a> <a href="/code-of-conduct">Code of Conduct</a> <br/>
<a href="/terms">Terms of Use</a> <a href="/contact">Contact</a> <a href="/code-of-conduct">Code of Conduct</a><br/>
<%= ApplicationConfig["COMMUNITY_NAME"] %> Community copyright 2016 - <%= Time.current.year %>&nbsp; 🔥
</div>
</div>
</footer>
<%= javascript_include_tag "hello-dev.js", defer: true %>
<%# javascript_include_tag "hello-dev.js", defer: true %>

View file

@ -1,4 +1,4 @@
<% cache "base_inline_styles_#{@story_show}_#{@article_index}_#{@home_page}_#{@article_show}_#{view_class}_#{@notifications_index}_#{core_pages?}_#{@tags_index}_#{@reading_list_items_index}_#{ApplicationConfig['DEPLOYMENT_SIGNATURE']}_#{user_signed_in?}", expires_in: 8.hours do %>
<% cache "base_inline_styles_#{@story_show}_#{@article_index}_#{@home_page}_#{@article_show}_#{view_class}_#{@notifications_index}_#{core_pages?}_#{@tags_index}_#{@reading_list_items_index}_#{@history_index}_#{ApplicationConfig['DEPLOYMENT_SIGNATURE']}_#{user_signed_in?}", expires_in: 8.hours do %>
<% if @story_show %>
<style>
<% Rails.application.config.assets.compile = true %>
@ -34,11 +34,12 @@
<%= Rails.application.assets["preact/sidebar-widget.css"].to_s.html_safe %>
<% end %>
</style>
<% elsif @notifications_index || @reading_list_items_index %>
<% elsif @notifications_index || @reading_list_items_index || @history_index %>
<style>
<% Rails.application.config.assets.compile = true %>
<%= Rails.application.assets["scaffolds.css"].to_s.html_safe %>
<%= Rails.application.assets["readinglist.css"].to_s.html_safe %>
<%= Rails.application.assets["history.css"].to_s.html_safe %>
<%= Rails.application.assets["articles.css"].to_s.html_safe %>
<%= Rails.application.assets["comments.css"].to_s.html_safe %>
<%= Rails.application.assets["notifications.css"].to_s.html_safe %>

View file

@ -44,7 +44,7 @@ Rails.application.routes.draw do
post "save_status"
end
end
resources :tags, only: %i[index show edit update]
resources :tags, only: %i[index edit update]
resources :users, only: %i[index show edit update] do
member do
post "banish"
@ -298,6 +298,7 @@ Rails.application.routes.draw do
get "/pod" => "podcast_episodes#index"
get "/readinglist" => "reading_list_items#index"
get "/readinglist/:view" => "reading_list_items#index", constraints: { view: /archive/ }
get "/history", to: "history#index", as: :history
get "/feed" => "articles#feed", as: "feed", defaults: { format: "rss" }
get "/feed/tag/:tag" => "articles#feed",
@ -307,7 +308,7 @@ Rails.application.routes.draw do
get "/rss" => "articles#feed", defaults: { format: "rss" }
get "/tag/:tag" => "stories#index"
get "/t/:tag" => "stories#index"
get "/t/:tag", to: "stories#index", as: :tag
get "/t/:tag/edit", to: "tags#edit"
get "/t/:tag/admin", to: "tags#admin"
patch "/tag/:id", to: "tags#update"

View file

@ -21,8 +21,8 @@
},
"lint-staged": {
"*.{js,jsx}": [
"eslint --fix",
"prettier --write",
"eslint --fix",
"git add"
],
"{app,spec}/**/*.rb": [
@ -85,15 +85,16 @@
"webpack-dev-server": "^2.11.3"
},
"dependencies": {
"@github/clipboard-copy-element": "^1.0.1",
"@rails/webpacker": "^3.5.5",
"babel-plugin-transform-react-jsx": "^6.24.1",
"babel-preset-preact": "^1.1.0",
"chart.js": "^2.8.0",
"codemirror": "^5.47.0",
"focus-visible": "^4.1.5",
"@github/clipboard-copy-element": "^1.0.1",
"intersection-observer": "^0.7.0",
"linkstate": "^1.1.1",
"lodash.debounce": "4.0.8",
"marked": "^0.6.2",
"preact": "^8.4.2",
"preact-compat": "^3.19.0",

View file

@ -0,0 +1,20 @@
require "rails_helper"
RSpec.describe "History", type: :request do
let(:user) { create(:user) }
let(:pro_user) { create(:user, :pro) }
describe "GET /history" do
it "does not allow access to a regular user" do
sign_in user
expect { get history_path }.to raise_error(Pundit::NotAuthorizedError)
end
it "allows access to a pro user" do
sign_in pro_user
get history_path
expect(response).to have_http_status(:ok)
expect(response.body).to include("History")
end
end
end

View file

@ -7012,7 +7012,7 @@ lodash.clonedeep@^4.3.2:
resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef"
integrity sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=
lodash.debounce@^4.0.8:
lodash.debounce@4.0.8, lodash.debounce@^4.0.8:
version "4.0.8"
resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
integrity sha1-gteb/zCmfEAF/9XiUVMArZyk168=