Nick/feature/search to preact (#532)
* Works, but lots of cleanup/questions. * Fixed comment. * Only want the search script to load once with InstantClick active. * Removed unnecessary DOMContentLoaded from copy paste. * 🔧 Enabled (P)React Devtools. * Updated comment about reactToEvent. * Removed unnecessary server-side generated JS for search. * <Search /> component now has component state. * Search functions are in there own module now. * 👷Refactor * Render propped it up. * Fixed issue with encoding of search term. * Removed data-no-instant attr from script. * 👷Refactor * Now flash of search loading is avoided.. * Moved search under components folder. * ✅Tests * fixedEncodeURIComponent does not need to be exported. * ✅Tests * Folders to ignore from the VS Code Local History extension. * ✅Tests * Snapshot test for <Search /> component. * ✅Tests * Now the <SearchContainer /> handles the '/' key shortcut. * Made the search box ID a prop defaulting to 'nav-search' * Excluded barrel files from code coverage. * Componentized search CSS. * Storybook stories for <Search /> component. * Removed extension folders to ignore. * Fixed logic for "/" key triggering search. * updated jest snapshot for <Search /> component. * updated package.json lock file. * Disabled the import/prefer-default-export rule. * Now search term clears if not on a non-search results page. * removed comment that is no longer valid. * No longer using render prop for Search. Doesn't make sense as it's not stuff that is reused. * Added preact-render-spy do dev deps.
This commit is contained in:
parent
b2b1068715
commit
18980034d3
24 changed files with 4621 additions and 4331 deletions
|
|
@ -25,14 +25,12 @@ function callInitalizers(){
|
|||
},1)
|
||||
initializeBaseTracking();
|
||||
initializeTouchDevice();
|
||||
initializeKeyListeners();
|
||||
initializeCommentsPage();
|
||||
initEditorResize();
|
||||
initLeaveEditorWarning();
|
||||
initializeArticleReactions();
|
||||
initNotifications();
|
||||
initializeSplitTestTracking();
|
||||
initializeNavSearchListener();
|
||||
initializeStylesheetAppend();
|
||||
initializeAnalytics();
|
||||
initializeCommentDropdown();
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
var scrollInterval;
|
||||
var lastPressedKey;
|
||||
var lastPressTime;
|
||||
var codeToWord = {191: "find"}
|
||||
function initializeKeyListeners() {
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (document.activeElement.tagName != "INPUT" && document.activeElement.tagName != "TEXTAREA" && !document.activeElement.classList.contains("input")) {
|
||||
reactToEvent(e)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function reactToEvent(event){
|
||||
if (codeToWord[event.which] === "find"){
|
||||
event.preventDefault();
|
||||
var searchBox = document.getElementById("nav-search")
|
||||
searchBox.focus();
|
||||
searchBox.select();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
function initializeNavSearchListener() {
|
||||
function fixedEncodeURIComponent(str) {
|
||||
// from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent
|
||||
return encodeURIComponent(str).replace(/[!'()*]/g, function(c) {
|
||||
return '%' + c.charCodeAt(0).toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('nav-search').onkeydown = function(event) {
|
||||
if (instantClick && event.keyCode === 13) {
|
||||
var encoded_query = fixedEncodeURIComponent(event.target.value.replace(/^[ ]+|[ ]+$/g, ''));
|
||||
InstantClick.preload("<%= ENV['APP_PROTOCOL'] %><%= ENV['APP_DOMAIN'] %>/search?q="+encoded_query+filterParams());
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('site-search-form').onsubmit = function(event) {
|
||||
if (instantClick) {
|
||||
event.preventDefault();
|
||||
var query = document.getElementById('nav-search').value.replace(/#/g,"%23").replace(/%/g,"%25");
|
||||
InstantClick.display("<%= ENV['APP_PROTOCOL'] %><%= ENV['APP_DOMAIN'] %>/search?q="+query+filterParams())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function filterParams(){
|
||||
var filters = getParameterByName("filters");
|
||||
if (filters){
|
||||
return "&filters="+filters;
|
||||
}
|
||||
else{
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function getParameterByName(name, url) {
|
||||
if (!url) url = window.location.href;
|
||||
name = name.replace(/[\[\]]/g, "\\$&");
|
||||
var regex = new RegExp("[?&]" + name + "(=([^&#]*)|&|#|$)"),
|
||||
results = regex.exec(url);
|
||||
if (!results) return null;
|
||||
if (!results[2]) return '';
|
||||
return decodeURIComponent(results[2].replace(/\+/g, " "));
|
||||
}
|
||||
|
|
@ -1,304 +1,311 @@
|
|||
@import 'variables';
|
||||
.stories-index,.comments-index,.users-index,.notifications-index{
|
||||
.top-bar{
|
||||
@media screen and ( min-width: 820px ){
|
||||
border-bottom:1.5px solid darken($lightest-gray,4%);
|
||||
box-shadow:0px 0px 2px 0px rgba(97, 95, 95, 0.18);
|
||||
.stories-index,
|
||||
.comments-index,
|
||||
.users-index,
|
||||
.notifications-index {
|
||||
.top-bar {
|
||||
@media screen and (min-width: 820px) {
|
||||
border-bottom: 1.5px solid darken($lightest-gray, 4%);
|
||||
box-shadow: 0px 0px 2px 0px rgba(97, 95, 95, 0.18);
|
||||
}
|
||||
}
|
||||
}
|
||||
.top-bar{
|
||||
.top-bar {
|
||||
font-family: $helvetica;
|
||||
position:absolute;
|
||||
top:0px;
|
||||
left:0px;
|
||||
right:0px;
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
right: 0px;
|
||||
-webkit-backface-visibility: hidden;
|
||||
z-index:1000;
|
||||
font-size:17px;
|
||||
height:48px;
|
||||
background:$tan;
|
||||
position:fixed;
|
||||
z-index: 1000;
|
||||
font-size: 17px;
|
||||
height: 48px;
|
||||
background: $tan;
|
||||
position: fixed;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.33);
|
||||
box-shadow:0 2px 4px 0 rgba(0, 0, 0, 0.13);
|
||||
nav{
|
||||
max-width:100%;
|
||||
width:1250px;
|
||||
margin:auto;
|
||||
position:relative;
|
||||
background:$tan;
|
||||
a{
|
||||
&:active{
|
||||
opacity:0.8;
|
||||
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.13);
|
||||
nav {
|
||||
max-width: 100%;
|
||||
width: 1250px;
|
||||
margin: auto;
|
||||
position: relative;
|
||||
background: $tan;
|
||||
a {
|
||||
&:active {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
.nav-link{
|
||||
position:absolute;
|
||||
top:17px;
|
||||
width:120px;
|
||||
height:30px;
|
||||
border-radius:2px;
|
||||
padding-right:10px;
|
||||
font-size:14px;
|
||||
font-weight:400;
|
||||
display:none;
|
||||
.nav-link {
|
||||
position: absolute;
|
||||
top: 17px;
|
||||
width: 120px;
|
||||
height: 30px;
|
||||
border-radius: 2px;
|
||||
padding-right: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
display: none;
|
||||
}
|
||||
.write{
|
||||
width:118px;
|
||||
.write {
|
||||
width: 118px;
|
||||
padding: 3px 0px;
|
||||
height: auto;
|
||||
top:12px;
|
||||
text-align:center;
|
||||
font-weight:bold;
|
||||
top: 12px;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
border-radius: 3px;
|
||||
border: 2px solid $black;
|
||||
@media screen and ( min-width: 560px ){
|
||||
display:block;
|
||||
right:140px;
|
||||
@media screen and (min-width: 560px) {
|
||||
display: block;
|
||||
right: 140px;
|
||||
}
|
||||
@media screen and ( min-width: 700px ){
|
||||
display:block;
|
||||
right:165px;
|
||||
@media screen and (min-width: 700px) {
|
||||
display: block;
|
||||
right: 165px;
|
||||
}
|
||||
}
|
||||
.connect-icon{
|
||||
width:29px;
|
||||
height:48px;
|
||||
display:block;
|
||||
text-align:center;
|
||||
z-index:10;
|
||||
top:1px;
|
||||
right:90px;
|
||||
@media screen and ( min-width:700px ){
|
||||
right:110px;
|
||||
.connect-icon {
|
||||
width: 29px;
|
||||
height: 48px;
|
||||
display: block;
|
||||
text-align: center;
|
||||
z-index: 10;
|
||||
top: 1px;
|
||||
right: 90px;
|
||||
@media screen and (min-width: 700px) {
|
||||
right: 110px;
|
||||
}
|
||||
img{
|
||||
margin-top:6px;
|
||||
height:33px;
|
||||
img {
|
||||
margin-top: 6px;
|
||||
height: 33px;
|
||||
}
|
||||
.connect-number{
|
||||
position:absolute;
|
||||
top:6px;
|
||||
left:17px;
|
||||
background:$green;
|
||||
padding:2px 7px;
|
||||
border-radius:8px;
|
||||
color:white;
|
||||
display:none;
|
||||
font-size:1.05em;
|
||||
&.showing{
|
||||
display:block;
|
||||
.connect-number {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 17px;
|
||||
background: $green;
|
||||
padding: 2px 7px;
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
display: none;
|
||||
font-size: 1.05em;
|
||||
&.showing {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
&:hover{
|
||||
img{
|
||||
opacity:0.9;
|
||||
&:hover {
|
||||
img {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.connect-number{
|
||||
background:$green;
|
||||
.connect-number {
|
||||
background: $green;
|
||||
}
|
||||
}
|
||||
}
|
||||
.notifications-icon{
|
||||
right:50px;
|
||||
top:5px;
|
||||
width:22px;
|
||||
height:41px;
|
||||
display:block;
|
||||
text-align:center;
|
||||
z-index:10;
|
||||
@media screen and ( min-width:700px ){
|
||||
right:73px;
|
||||
.notifications-icon {
|
||||
right: 50px;
|
||||
top: 5px;
|
||||
width: 22px;
|
||||
height: 41px;
|
||||
display: block;
|
||||
text-align: center;
|
||||
z-index: 10;
|
||||
@media screen and (min-width: 700px) {
|
||||
right: 73px;
|
||||
}
|
||||
img{
|
||||
margin-top:10px;
|
||||
height:23px;
|
||||
img {
|
||||
margin-top: 10px;
|
||||
height: 23px;
|
||||
}
|
||||
.notifications-number{
|
||||
position:absolute;
|
||||
top:2px;
|
||||
left:11px;
|
||||
background:$dark-purple;
|
||||
padding:2px 7px;
|
||||
border-radius:8px;
|
||||
color:white;
|
||||
display:none;
|
||||
font-size:1.05em;
|
||||
&.showing{
|
||||
display:block;
|
||||
.notifications-number {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 11px;
|
||||
background: $dark-purple;
|
||||
padding: 2px 7px;
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
display: none;
|
||||
font-size: 1.05em;
|
||||
&.showing {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
&:hover{
|
||||
img{
|
||||
opacity:0.9;
|
||||
&:hover {
|
||||
img {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.notifications-number{
|
||||
background:$dark-purple;
|
||||
.notifications-number {
|
||||
background: $dark-purple;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
.nav-search-form{
|
||||
position:absolute;
|
||||
top:7px;
|
||||
left:calc(50px + 5vw);
|
||||
bottom: 0px;
|
||||
width: calc(55vw - 63px);
|
||||
@media screen and ( min-width: 350px ){
|
||||
width: calc(61vw - 75px);
|
||||
}
|
||||
@media screen and ( min-width: 450px ){
|
||||
width: calc(61vw - 45px);
|
||||
}
|
||||
@media screen and ( min-width: 560px ){
|
||||
width: calc(46vw - 52px);
|
||||
}
|
||||
@media screen and ( min-width: 950px ){
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: auto;
|
||||
width:300px;
|
||||
}
|
||||
@media screen and ( min-width: 1120px ){
|
||||
width:calc(100% - 760px);
|
||||
}
|
||||
input{
|
||||
margin:0;
|
||||
width: calc(100% - 12px);
|
||||
border-radius:3px;
|
||||
border:0px;
|
||||
padding:8px;
|
||||
font-size:0.9em;
|
||||
background:#e8e7e7;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
}
|
||||
.nav-profile-image-wrapper{
|
||||
.nav-profile-image-wrapper {
|
||||
height: 33px;
|
||||
width: 33px;
|
||||
background: $dark-gray;
|
||||
margin-top:-6px;
|
||||
margin-top: -6px;
|
||||
border-radius: 100%;
|
||||
border: 0;
|
||||
overflow: hidden;
|
||||
img{
|
||||
img {
|
||||
height: 35px;
|
||||
width: 35px;
|
||||
border-radius: 100%;
|
||||
margin-top:-1px;
|
||||
margin-left:-1px;
|
||||
margin-top: -1px;
|
||||
margin-left: -1px;
|
||||
}
|
||||
}
|
||||
.bars{
|
||||
height:22px;
|
||||
width:22px;
|
||||
background:transparent;
|
||||
.bars {
|
||||
height: 22px;
|
||||
width: 22px;
|
||||
background: transparent;
|
||||
}
|
||||
.navbar-menu-wrapper{
|
||||
position:absolute;
|
||||
right:0px;
|
||||
top:0px;
|
||||
&.showing{
|
||||
.menu,.menubg{
|
||||
display:block;
|
||||
.navbar-menu-wrapper {
|
||||
position: absolute;
|
||||
right: 0px;
|
||||
top: 0px;
|
||||
&.showing {
|
||||
.menu,
|
||||
.menubg {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
&.desktop{
|
||||
&:hover{
|
||||
.menu{
|
||||
display:block;
|
||||
&.desktop {
|
||||
&:hover {
|
||||
.menu {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigation-butt{
|
||||
.navigation-butt {
|
||||
all: unset;
|
||||
position:absolute;
|
||||
right:0px;
|
||||
top:8px;
|
||||
padding-top:6px;
|
||||
padding-bottom:6px;
|
||||
padding-left:28px;
|
||||
padding-right:6px;
|
||||
border-bottom-left-radius:8px;
|
||||
position: absolute;
|
||||
right: 0px;
|
||||
top: 8px;
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
padding-left: 28px;
|
||||
padding-right: 6px;
|
||||
border-bottom-left-radius: 8px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor:pointer;
|
||||
@media screen and ( min-width: 700px ){
|
||||
padding-right:33px;
|
||||
cursor: pointer;
|
||||
@media screen and (min-width: 700px) {
|
||||
padding-right: 33px;
|
||||
}
|
||||
}
|
||||
.menubg{
|
||||
position:fixed;
|
||||
left:0;right:0;bottom:0;
|
||||
background:rgba(17, 17, 17, 0.88);
|
||||
z-index:-5;
|
||||
top:40px;
|
||||
display:none;
|
||||
.menubg {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(17, 17, 17, 0.88);
|
||||
z-index: -5;
|
||||
top: 40px;
|
||||
display: none;
|
||||
}
|
||||
.menu{
|
||||
position:absolute;
|
||||
right:-3px;
|
||||
padding:5px 10px;
|
||||
text-align:left;
|
||||
.menu {
|
||||
position: absolute;
|
||||
right: -3px;
|
||||
padding: 5px 10px;
|
||||
text-align: left;
|
||||
background: $tan;
|
||||
border: 1px solid rgb(219,222,225);
|
||||
border-top:0px;
|
||||
display:none;
|
||||
width:calc(100vw - 18px);
|
||||
border: 1px solid rgb(219, 222, 225);
|
||||
border-top: 0px;
|
||||
display: none;
|
||||
width: calc(100vw - 18px);
|
||||
top: 45px;
|
||||
@media screen and ( min-width: 380px ){
|
||||
@media screen and (min-width: 380px) {
|
||||
position: fixed;
|
||||
width: 230px;
|
||||
border-right: none;
|
||||
padding: 15px 10px;
|
||||
border-bottom-left-radius: 5px;
|
||||
box-shadow: 0px 4px 5px 1px rgba(97, 95, 95, 0.18)
|
||||
box-shadow: 0px 4px 5px 1px rgba(97, 95, 95, 0.18);
|
||||
}
|
||||
@media screen and ( min-width: 1250px) {
|
||||
@media screen and (min-width: 1250px) {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
}
|
||||
.header{
|
||||
padding:3px 10px;
|
||||
.header {
|
||||
padding: 3px 10px;
|
||||
border-bottom: 3px solid rgb(241, 244, 246);
|
||||
margin-bottom:5px;
|
||||
color:rgb(178, 192, 203);
|
||||
margin-bottom: 5px;
|
||||
color: rgb(178, 192, 203);
|
||||
}
|
||||
.option{
|
||||
padding:15px 10px;
|
||||
@media screen and ( min-width: 380px ){
|
||||
padding:6px 10px;
|
||||
.option {
|
||||
padding: 15px 10px;
|
||||
@media screen and (min-width: 380px) {
|
||||
padding: 6px 10px;
|
||||
}
|
||||
&.prime-option{
|
||||
border-bottom:3px solid #7a7e81;
|
||||
margin-bottom:5px;
|
||||
font-size:1.22em;
|
||||
font-weight:500;
|
||||
&.prime-option {
|
||||
border-bottom: 3px solid #7a7e81;
|
||||
margin-bottom: 5px;
|
||||
font-size: 1.22em;
|
||||
font-weight: 500;
|
||||
}
|
||||
&:hover{
|
||||
background:darken($tan,2%);
|
||||
&:hover {
|
||||
background: darken($tan, 2%);
|
||||
}
|
||||
}
|
||||
a{
|
||||
color:$black;
|
||||
a {
|
||||
color: $black;
|
||||
}
|
||||
}
|
||||
}
|
||||
// border-bottom:2px solid rgb(10, 10, 10);
|
||||
.logo-link{
|
||||
display:inline-block;
|
||||
margin-left:2%;
|
||||
.logo-link {
|
||||
display: inline-block;
|
||||
margin-left: 2%;
|
||||
}
|
||||
.logo{
|
||||
width:33px;
|
||||
height:21px;
|
||||
padding:7px 8px 6px 10px;
|
||||
margin-top:7px;
|
||||
fill:white;
|
||||
.logo {
|
||||
width: 33px;
|
||||
height: 21px;
|
||||
padding: 7px 8px 6px 10px;
|
||||
margin-top: 7px;
|
||||
fill: white;
|
||||
border-radius: 3px;
|
||||
// border-right:1px solid #a5a6a8;
|
||||
// border-left:1px solid #a5a6a8;
|
||||
background:$black;
|
||||
background: $black;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-search-form {
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
left: calc(50px + 5vw);
|
||||
bottom: 0px;
|
||||
width: calc(55vw - 63px);
|
||||
@media screen and (min-width: 350px) {
|
||||
width: calc(61vw - 75px);
|
||||
}
|
||||
@media screen and (min-width: 450px) {
|
||||
width: calc(61vw - 45px);
|
||||
}
|
||||
@media screen and (min-width: 560px) {
|
||||
width: calc(46vw - 52px);
|
||||
}
|
||||
@media screen and (min-width: 950px) {
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: auto;
|
||||
width: 300px;
|
||||
}
|
||||
@media screen and (min-width: 1120px) {
|
||||
width: calc(100% - 760px);
|
||||
}
|
||||
&__input {
|
||||
margin: 0;
|
||||
width: calc(100% - 12px);
|
||||
border-radius: 3px;
|
||||
border: 0px;
|
||||
padding: 8px;
|
||||
font-size: 0.9em;
|
||||
background: #e8e7e7;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,5 +30,10 @@ module.exports = {
|
|||
devDependencies: ['**/*.test.js', '**/*.test.jsx', '**/*.stories.jsx'],
|
||||
},
|
||||
],
|
||||
'import/prefer-default-export': 'off',
|
||||
},
|
||||
globals: {
|
||||
InstantClick: false,
|
||||
filterXSS: false,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
8
app/javascript/packs/Search.jsx
Normal file
8
app/javascript/packs/Search.jsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { h, render } from 'preact';
|
||||
import { Search } from '../src/components/Search';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const root = document.getElementById('nav-search-form-root');
|
||||
|
||||
render(<Search />, root, root.firstElementChild);
|
||||
});
|
||||
113
app/javascript/src/components/Search/Search.jsx
Normal file
113
app/javascript/src/components/Search/Search.jsx
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import 'preact/devtools';
|
||||
import { Component, h } from 'preact';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
getInitialSearchTerm,
|
||||
hasInstantClick,
|
||||
preloadSearchResults,
|
||||
displaySearchResults,
|
||||
} from '../../utils/search';
|
||||
import { SearchForm } from './SearchForm';
|
||||
|
||||
const GLOBAL_SEARCH_KEY_CODE = 191;
|
||||
const ENTER_KEY_CODE = 13;
|
||||
|
||||
export class Search extends Component {
|
||||
static defaultProps = {
|
||||
searchBoxId: 'nav-search',
|
||||
};
|
||||
|
||||
componentWillMount() {
|
||||
this.setState(
|
||||
{ searchTerm: getInitialSearchTerm(window.location.search) },
|
||||
() => preloadSearchResults({ searchTerm: this.state.searchTerm }),
|
||||
);
|
||||
const searchPageChecker = () => {
|
||||
if (
|
||||
this.enableSearchPageChecker &&
|
||||
this.state.searchTerm !== '' &&
|
||||
/^http(s)?:\/\/[^/]+\/search/.exec(window.location.href) === null
|
||||
) {
|
||||
this.setState({ searchTerm: '' });
|
||||
}
|
||||
|
||||
setTimeout(searchPageChecker, 500);
|
||||
};
|
||||
|
||||
searchPageChecker();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.registerGlobalSearchKeyListener();
|
||||
InstantClick.on('change', this.enableSearchPageListener);
|
||||
}
|
||||
|
||||
componentDidUnmount() {
|
||||
document.removeEventListener('keydown', this.globalSearchKeyListener);
|
||||
InstantClick.off('change', this.enableSearchPageListener);
|
||||
}
|
||||
|
||||
enableSearchPageChecker = true;
|
||||
globalSearchKeyListener;
|
||||
enableSearchPageListener = () => {
|
||||
this.enableSearchPageChecker = true;
|
||||
};
|
||||
|
||||
registerGlobalSearchKeyListener() {
|
||||
const searchBox = document.getElementById(this.props.searchBoxId);
|
||||
|
||||
this.globalSearchKeyListener = event => {
|
||||
const { tagName, classList } = document.activeElement;
|
||||
if (
|
||||
event.which !== GLOBAL_SEARCH_KEY_CODE ||
|
||||
tagName === 'INPUT' ||
|
||||
tagName === 'TEXTAREA' ||
|
||||
classList.contains('input')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
searchBox.focus();
|
||||
searchBox.select();
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', this.globalSearchKeyListener);
|
||||
}
|
||||
|
||||
search = event => {
|
||||
const { keyCode, target: { value } } = event;
|
||||
|
||||
this.enableSearchPageChecker = false;
|
||||
|
||||
if (hasInstantClick() && keyCode === ENTER_KEY_CODE) {
|
||||
this.setState({ searchTerm: value }, () => {
|
||||
preloadSearchResults({ searchTerm: this.state.searchTerm });
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
submit = event => {
|
||||
if (hasInstantClick) {
|
||||
const { searchTerm } = this.state;
|
||||
|
||||
event.preventDefault();
|
||||
displaySearchResults({ searchTerm });
|
||||
}
|
||||
};
|
||||
|
||||
render({ searchBoxId }, { searchTerm = '' }) {
|
||||
return (
|
||||
<SearchForm
|
||||
searchTerm={searchTerm}
|
||||
onSearch={this.search}
|
||||
onSubmitSearch={this.submit}
|
||||
searchBoxId={searchBoxId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Search.propTypes = {
|
||||
searchBoxId: PropTypes.string,
|
||||
};
|
||||
38
app/javascript/src/components/Search/SearchForm.jsx
Normal file
38
app/javascript/src/components/Search/SearchForm.jsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import 'preact/devtools';
|
||||
import PropTypes from 'prop-types';
|
||||
import { h } from 'preact';
|
||||
|
||||
export const SearchForm = ({
|
||||
searchTerm,
|
||||
onSearch,
|
||||
onSubmitSearch,
|
||||
searchBoxId,
|
||||
}) => (
|
||||
<div className="nav-search-form">
|
||||
<form
|
||||
action="/search"
|
||||
acceptCharset="UTF-8"
|
||||
method="get"
|
||||
onSubmit={onSubmitSearch}
|
||||
>
|
||||
<input name="utf8" type="hidden" value="✓" />
|
||||
<input
|
||||
className="nav-search-form__input"
|
||||
type="text"
|
||||
name="q"
|
||||
id={searchBoxId}
|
||||
placeholder="search"
|
||||
autoComplete="off"
|
||||
onKeyDown={onSearch}
|
||||
value={searchTerm}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
|
||||
SearchForm.propTypes = {
|
||||
searchTerm: PropTypes.string.isRequired,
|
||||
searchBoxId: PropTypes.string.isRequired,
|
||||
onSearch: PropTypes.func.isRequired,
|
||||
onSubmitSearch: PropTypes.func.isRequired,
|
||||
};
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { h } from 'preact';
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { SearchForm } from '..';
|
||||
|
||||
const commonProps = {
|
||||
searchBoxId: 'nav-search',
|
||||
onSearch: action('on preloading search'),
|
||||
onSubmitSearch: e => {
|
||||
e.preventDefault();
|
||||
action('on submit')(e);
|
||||
},
|
||||
};
|
||||
|
||||
storiesOf('Search/Search Form', module)
|
||||
.add('No search term', () => <SearchForm {...commonProps} searchTerm="" />)
|
||||
.add('With search term', () => (
|
||||
<SearchForm {...commonProps} searchTerm="Hello" />
|
||||
));
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import { h } from 'preact';
|
||||
import render from 'preact-render-to-json';
|
||||
import { Search } from '../Search';
|
||||
|
||||
describe('<Search />', () => {
|
||||
beforeEach(() => {
|
||||
global.filterXSS = x => x;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.filterXSS = undefined;
|
||||
});
|
||||
|
||||
it('renders properly', () => {
|
||||
const tree = render(<Search />);
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import { h } from 'preact';
|
||||
import render from 'preact-render-to-json';
|
||||
import { SearchForm } from '../Search';
|
||||
|
||||
describe('<SearchForm />', () => {
|
||||
it('renders properly when given search functions and a search term', () => {
|
||||
const onSubmitSearch = jest.fn();
|
||||
const onSearch = jest.fn();
|
||||
const searchTerm = 'hello';
|
||||
const tree = render(
|
||||
<SearchForm
|
||||
onSubmitSearch={onSubmitSearch}
|
||||
onSearch={onSearch}
|
||||
searchTerm={searchTerm}
|
||||
searchBoxId="nav-search"
|
||||
/>,
|
||||
);
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`<Search /> renders properly 1`] = `
|
||||
<div
|
||||
class="nav-search-form"
|
||||
>
|
||||
<form
|
||||
acceptCharset="UTF-8"
|
||||
action="/search"
|
||||
method="get"
|
||||
onSubmit={[Function]}
|
||||
>
|
||||
<input
|
||||
name="utf8"
|
||||
type="hidden"
|
||||
value="✓"
|
||||
/>
|
||||
<input
|
||||
autoComplete="off"
|
||||
class="nav-search-form__input"
|
||||
id="nav-search"
|
||||
name="q"
|
||||
onKeyDown={[Function]}
|
||||
placeholder="search"
|
||||
type="text"
|
||||
value=""
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`<SearchForm /> renders properly when given search functions and a search term 1`] = `
|
||||
VNode {
|
||||
"attributes": Object {
|
||||
"onSearch": [MockFunction],
|
||||
"onSubmitSearch": [MockFunction],
|
||||
"searchBoxId": "nav-search",
|
||||
"searchTerm": "hello",
|
||||
},
|
||||
"children": Array [],
|
||||
"key": undefined,
|
||||
"nodeName": undefined,
|
||||
}
|
||||
`;
|
||||
2
app/javascript/src/components/Search/index.js
Normal file
2
app/javascript/src/components/Search/index.js
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './Search';
|
||||
export * from './SearchForm';
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
import { h } from 'preact';
|
||||
import GlobalModalWrapper from './GlobalModalWrapper';
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const globalModalDecorator = story => (
|
||||
<GlobalModalWrapper>{story()}</GlobalModalWrapper>
|
||||
);
|
||||
|
|
|
|||
217
app/javascript/src/utils/__tests__/search.test.js
Normal file
217
app/javascript/src/utils/__tests__/search.test.js
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
import {
|
||||
getInitialSearchTerm,
|
||||
preloadSearchResults,
|
||||
hasInstantClick,
|
||||
displaySearchResults,
|
||||
} from '../search';
|
||||
import '../../../../assets/javascripts/lib/xss';
|
||||
|
||||
describe('Search utilities', () => {
|
||||
describe('getInitialSearchTerm', () => {
|
||||
describe(`When the querystring key 'q' has a value`, () => {
|
||||
test(`should return the querystring key q's value`, () => {
|
||||
const expected = 'hello';
|
||||
const querystring = `?q=${expected}`;
|
||||
const actual = getInitialSearchTerm(querystring);
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe(`When the querystring key 'q' has an encoded value with markup`, () => {
|
||||
test(`should return the querystring key q's decoded value`, () => {
|
||||
const expected = `<script>alert('XSS!');</script>`;
|
||||
const querystring = `?q=<script>alert(%27XSS!%27);</script>`;
|
||||
const actual = getInitialSearchTerm(querystring);
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe(`When the querystring key 'q' has no value`, () => {
|
||||
test(`should return an empty string`, () => {
|
||||
const expected = '';
|
||||
const querystring = `?q=`;
|
||||
const actual = getInitialSearchTerm(querystring);
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe(`When the querystring key 'q' does not exist`, () => {
|
||||
test(`should return an empty string`, () => {
|
||||
const expected = '';
|
||||
const querystring = '?';
|
||||
const actual = getInitialSearchTerm(querystring);
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('preloadSearchResults', () => {
|
||||
beforeEach(() => {
|
||||
global.InstantClick = {
|
||||
preload: url => url,
|
||||
};
|
||||
jest.spyOn(InstantClick, 'preload');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
delete global.InstantClick;
|
||||
});
|
||||
|
||||
test('should call InstantClick.preLoad', () => {
|
||||
const location = {
|
||||
origin: 'http://localhost',
|
||||
href: 'http://localhost',
|
||||
};
|
||||
preloadSearchResults({
|
||||
location,
|
||||
searchTerm: 'hello',
|
||||
});
|
||||
|
||||
expect(InstantClick.preload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe('When a search term is passed in', () => {
|
||||
test('should call InstantClick.preLoad with the search term value in the URL', () => {
|
||||
const location = {
|
||||
origin: 'http://localhost',
|
||||
href: 'http://localhost',
|
||||
};
|
||||
const searchTerm = 'hello';
|
||||
const expected = `${location.origin}/search?q=${searchTerm}`;
|
||||
|
||||
preloadSearchResults({ searchTerm, location });
|
||||
|
||||
expect(InstantClick.preload).toBeCalledWith(expected);
|
||||
});
|
||||
|
||||
test('should call InstantClick.preLoad with the encoded value for the search term', () => {
|
||||
const location = {
|
||||
origin: 'http://localhost',
|
||||
href: 'http://localhost',
|
||||
};
|
||||
|
||||
const searchTerm = 'hello+everybody!';
|
||||
const expected = `${location.origin}/search?q=hello%2Beverybody%21`;
|
||||
|
||||
preloadSearchResults({ searchTerm, location });
|
||||
|
||||
expect(InstantClick.preload).toBeCalledWith(expected);
|
||||
});
|
||||
|
||||
test('should call InstantClick.preLoad if the search term is empty', () => {
|
||||
const location = {
|
||||
origin: 'http://localhost',
|
||||
href: 'http://localhost',
|
||||
};
|
||||
const searchTerm = '';
|
||||
|
||||
preloadSearchResults({ searchTerm, location });
|
||||
|
||||
expect(InstantClick.preload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasInstantClick', () => {
|
||||
describe('When instant click exists', () => {
|
||||
test('should return true', () => {
|
||||
global.instantClick = {};
|
||||
expect(hasInstantClick()).toEqual(true);
|
||||
delete global.instantClick;
|
||||
});
|
||||
});
|
||||
|
||||
describe('When instant click does not exist', () => {
|
||||
test('should return false', () => {
|
||||
expect(hasInstantClick()).toEqual(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('displaySearchResults', () => {
|
||||
beforeEach(() => {
|
||||
global.InstantClick = {
|
||||
display: url => url,
|
||||
};
|
||||
jest.spyOn(InstantClick, 'display');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
delete global.InstantClick;
|
||||
});
|
||||
|
||||
test('should call InstantClick.display if search term is empty', () => {
|
||||
displaySearchResults({ searchTerm: '', location: { href: '' } });
|
||||
|
||||
expect(InstantClick.display).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('should call InstantClick.display once', () => {
|
||||
displaySearchResults({ searchTerm: 'hello', location: { href: '' } });
|
||||
|
||||
expect(InstantClick.display).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('should call InstantClick.display with the correct search URL', () => {
|
||||
const searchTerm = 'hello';
|
||||
const location = {
|
||||
origin: 'http://localhost',
|
||||
};
|
||||
|
||||
displaySearchResults({ searchTerm, location });
|
||||
|
||||
expect(InstantClick.display).toBeCalledWith(
|
||||
`${location.origin}/search?q=${searchTerm}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('should call InstantClick.display with an encoded search term', () => {
|
||||
const searchTerm = '#hello%';
|
||||
const sanitizedSearchTerm = '%23hello%25';
|
||||
const location = {
|
||||
origin: 'http://localhost',
|
||||
};
|
||||
|
||||
displaySearchResults({ searchTerm, location });
|
||||
|
||||
expect(InstantClick.display).toBeCalledWith(
|
||||
`${location.origin}/search?q=${sanitizedSearchTerm}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('should call InstantClick.display with filters, if present', () => {
|
||||
const searchTerm = '#hello%';
|
||||
const sanitizedSearchTerm = '%23hello%25';
|
||||
const filterParameters = 'class_name:Article';
|
||||
const location = {
|
||||
origin: 'http://localhost',
|
||||
href: `http://localhost?filters=${filterParameters}`,
|
||||
};
|
||||
|
||||
displaySearchResults({ searchTerm, location });
|
||||
|
||||
expect(InstantClick.display).toBeCalledWith(
|
||||
`${
|
||||
location.origin
|
||||
}/search?q=${sanitizedSearchTerm}&filters=${filterParameters}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('should not call InstantClick.display with filters, if filter querystring key is present, but has no value', () => {
|
||||
const searchTerm = '#hello%';
|
||||
const sanitizedSearchTerm = '%23hello%25';
|
||||
const location = {
|
||||
origin: 'http://localhost',
|
||||
href: 'http://localhost?filters=',
|
||||
};
|
||||
|
||||
displaySearchResults({ searchTerm, location });
|
||||
|
||||
expect(InstantClick.display).toBeCalledWith(
|
||||
`${location.origin}/search?q=${sanitizedSearchTerm}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
80
app/javascript/src/utils/search.js
Normal file
80
app/javascript/src/utils/search.js
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
// TODO: We should really be using the xss package by installing it in package.json
|
||||
// but for now filterXSS is global because of legacy JS
|
||||
|
||||
function getParameterByName(name, url = window.location.href) {
|
||||
const sanitizedName = name.replace(/[[\]]/g, '\\$&');
|
||||
const regex = new RegExp(`[?&]${sanitizedName}(=([^&#]*)|&|#|$)`);
|
||||
const results = regex.exec(url);
|
||||
|
||||
if (!results) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!results[2]) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return decodeURIComponent(results[2].replace(/\+/g, ' '));
|
||||
}
|
||||
|
||||
function getFilterParameters(url) {
|
||||
const filters = getParameterByName('filters', url);
|
||||
|
||||
if (filters) {
|
||||
return `&filters=${filters}`;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
export const hasInstantClick = () => typeof instantClick !== 'undefined';
|
||||
|
||||
function fixedEncodeURIComponent(str) {
|
||||
// from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent
|
||||
return encodeURIComponent(str).replace(
|
||||
/[!'()*]/g,
|
||||
c => `%${c.charCodeAt(0).toString(16)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function displaySearchResults({
|
||||
searchTerm,
|
||||
location = window.location,
|
||||
}) {
|
||||
const baseUrl = location.origin;
|
||||
const sanitizedQuery = fixedEncodeURIComponent(searchTerm);
|
||||
const filterParameters = getFilterParameters(location.href);
|
||||
|
||||
InstantClick.display(
|
||||
`${baseUrl}/search?q=${sanitizedQuery}${filterParameters}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function getInitialSearchTerm(querystring) {
|
||||
const matches = /(?:&|\?)?q=([^&=]+)/.exec(querystring);
|
||||
const rawSearchTerm =
|
||||
matches !== null && matches.length === 2
|
||||
? decodeURIComponent(matches[1])
|
||||
: '';
|
||||
const query = filterXSS(rawSearchTerm);
|
||||
const divForDecode = document.createElement('div');
|
||||
divForDecode.innerHTML = query;
|
||||
|
||||
return divForDecode.firstChild !== null
|
||||
? divForDecode.firstChild.nodeValue
|
||||
: query;
|
||||
}
|
||||
|
||||
export function preloadSearchResults({
|
||||
searchTerm,
|
||||
location = window.location,
|
||||
}) {
|
||||
const encodedQuery = fixedEncodeURIComponent(
|
||||
searchTerm.replace(/^[ ]+|[ ]+$/g, ''),
|
||||
);
|
||||
InstantClick.preload(
|
||||
`${location.origin}/search?q=${encodedQuery}${getFilterParameters(
|
||||
location.href,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
|
|
@ -19,11 +19,6 @@
|
|||
function algoliaMain() {
|
||||
var query = filterXSS(params.q);
|
||||
var filters = filterXSS(params.filters || "");
|
||||
var div_for_decode = document.createElement('div');
|
||||
div_for_decode.innerHTML = query;
|
||||
var search_query = div_for_decode.firstChild.nodeValue;
|
||||
document.activeElement.blur();
|
||||
document.getElementById("nav-search").value = search_query
|
||||
document.getElementById("substories").innerHTML = '<div class="query-results-nothing"><div class="query-results-loader"></div><br/></div>'
|
||||
var client = algoliasearch('<%= ENV["ALGOLIASEARCH_APPLICATION_ID"] %>', '<%= ENV["ALGOLIASEARCH_PUBLIC_SEARCH_ONLY_KEY"] %>');
|
||||
var index = client.initIndex('searchables_<%= Rails.env %>');
|
||||
|
|
|
|||
|
|
@ -3,10 +3,12 @@
|
|||
<div class="top-bar" id="top-bar">
|
||||
<nav>
|
||||
<a href="/" class="logo-link" id="logo-link" aria-label="DEV Home"><%= inline_svg("devplain.svg", class: "logo", size: '20% * 20%') %></a>
|
||||
<div class="nav-search-form" id="nav-search-form">
|
||||
<%= form_tag( '/search', :method => :get, id: "site-search-form" ) do %>
|
||||
<%= text_field_tag :q, nil, placeholder: "search", id: "nav-search", autocomplete: :off %>
|
||||
<% end %>
|
||||
<div id="nav-search-form-root">
|
||||
<div class="nav-search-form">
|
||||
<form acceptCharset="UTF-8" method="get">
|
||||
<input class="nav-search-form__input" type="text" name="q" id="nav-search" placeholder="search" autoComplete="off" />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/new" id="write-link" class="cta nav-link write">WRITE A POST</a>
|
||||
<a href="/connect" id="connect-link" class="nav-link connect-icon" aria-label="Connect">
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
<meta name="algolia-public-id" content="<%= ENV["ALGOLIASEARCH_APPLICATION_ID"] %>">
|
||||
<meta name="algolia-public-key" content="<%= ENV["ALGOLIASEARCH_PUBLIC_SEARCH_ONLY_KEY"] %>">
|
||||
<meta name="environment" content="<%= Rails.env %>">
|
||||
|
||||
|
||||
<%= render "layouts/styles" %>
|
||||
<style>
|
||||
.home {
|
||||
|
|
@ -33,6 +33,7 @@
|
|||
}
|
||||
}
|
||||
</style>
|
||||
<%= javascript_pack_tag 'Search', defer: true %>
|
||||
<% if core_pages? %>
|
||||
<%= javascript_include_tag 'base', defer: true %>
|
||||
<% if user_signed_in? %>
|
||||
|
|
@ -84,11 +85,6 @@
|
|||
<div id="page-content-inner">
|
||||
<%= yield %>
|
||||
</div>
|
||||
<% unless @query.present? %>
|
||||
<script async>
|
||||
document.getElementById("nav-search").value = ""
|
||||
</script>
|
||||
<% end %>
|
||||
</div>
|
||||
<% unless is_internal_navigation? %>
|
||||
<% cache("footer-and-signup-modal--#{user_signed_in?}--#{ENV["DEPLOYMENT_SIGNATURE"].to_s}", :expires_in => 200.hours) do %>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
module.exports = {
|
||||
collectCoverageFrom: [
|
||||
'app/javascript/src/**/*.{js,jsx}',
|
||||
// This exclusion avoids running coverage on Barrel files, https://twitter.com/housecor/status/981558704708472832
|
||||
'!app/javascript/src/**/components/**/index.js',
|
||||
'!**/__tests__/**',
|
||||
'!**/__stories__/**',
|
||||
],
|
||||
|
|
|
|||
7840
package-lock.json
generated
7840
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -74,7 +74,7 @@
|
|||
"lint-staged": "^7.0.5",
|
||||
"node-sass": "^4.9.0",
|
||||
"preact-compat": "^3.18.0",
|
||||
"preact-render-spy": "^1.2.1",
|
||||
"preact-render-spy": "^1.3.0",
|
||||
"preact-render-to-json": "^3.6.6",
|
||||
"prettier": "^1.11.1",
|
||||
"sass-loader": "^7.0.2",
|
||||
|
|
|
|||
|
|
@ -7517,9 +7517,9 @@ preact-compat@^3.18.0:
|
|||
prop-types "^15.5.8"
|
||||
standalone-react-addons-pure-render-mixin "^0.1.1"
|
||||
|
||||
preact-render-spy@^1.2.1:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/preact-render-spy/-/preact-render-spy-1.2.1.tgz#5f4101c8f15f39162db7d274230935cfb3f07f5c"
|
||||
preact-render-spy@^1.3.0:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/preact-render-spy/-/preact-render-spy-1.3.0.tgz#f64b83f4de33d9696e69e5b08c4ae5e29decd6d1"
|
||||
dependencies:
|
||||
lodash.isequal "^4.5.0"
|
||||
object.entries "^1.0.4"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue