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:
Nick Taylor 2018-07-19 17:02:48 -04:00 committed by Ben Halpern
parent b2b1068715
commit 18980034d3
24 changed files with 4621 additions and 4331 deletions

View file

@ -25,14 +25,12 @@ function callInitalizers(){
},1)
initializeBaseTracking();
initializeTouchDevice();
initializeKeyListeners();
initializeCommentsPage();
initEditorResize();
initLeaveEditorWarning();
initializeArticleReactions();
initNotifications();
initializeSplitTestTracking();
initializeNavSearchListener();
initializeStylesheetAppend();
initializeAnalytics();
initializeCommentDropdown();

View file

@ -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();
}
}

View file

@ -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, " "));
}

View file

@ -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;
}
}

View file

@ -30,5 +30,10 @@ module.exports = {
devDependencies: ['**/*.test.js', '**/*.test.jsx', '**/*.stories.jsx'],
},
],
'import/prefer-default-export': 'off',
},
globals: {
InstantClick: false,
filterXSS: false,
},
};

View 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);
});

View 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,
};

View 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,
};

View file

@ -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" />
));

View file

@ -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();
});
});

View file

@ -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();
});
});

View file

@ -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>
`;

View file

@ -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,
}
`;

View file

@ -0,0 +1,2 @@
export * from './Search';
export * from './SearchForm';

View file

@ -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>
);

View 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}`,
);
});
});
});

View 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,
)}`,
);
}

View file

@ -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 %>');

View file

@ -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">

View file

@ -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 %>

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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",

View file

@ -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"