docbrown/app/javascript/listings/listings.jsx
Jacob Herrington 2f703e97f3
Fix bug when responding to listings (#13366)
* Fix bug when responding to listings

Sending a message to a listing author works about half the time. I
can't determine exactly what user behavior causes it to fail or succeed,
but after making this change it doesn't fail anymore. Looking at the
code, I'm not sure how it ever works...

* Notify Honeybadger when listing responses fail

* Add E2E test for contacting through listings

* Update e2e test
2021-04-23 13:55:33 -05:00

313 lines
8.6 KiB
JavaScript

import { h, Component } from 'preact';
import { debounceAction } from '../utilities/debounceAction';
import { fetchSearch } from '../utilities/search';
import { Modal } from './components/Modal';
import { AllListings } from './components/AllListings';
import { ListingFilters } from './components/ListingFilters';
import {
LISTING_PAGE_SIZE,
updateListings,
getQueryParams,
resizeAllMasonryItems,
getLocation,
} from './utils';
export class Listings extends Component {
state = {
listings: [],
query: '',
tags: [],
category: '',
allCategories: [],
initialFetch: true,
currentUserId: null,
openedListing: null,
message: '',
slug: null,
page: 0,
showNextPageButton: false,
isModalOpen: false,
};
componentWillMount() {
const params = getQueryParams();
const container = document.getElementById('listings-index-container');
const category = container.dataset.category || '';
const allCategories = JSON.parse(container.dataset.allcategories || []);
let tags = [];
let openedListing = null;
let slug = null;
let listings = [];
let isModalOpen = false;
if (params.t) {
tags = params.t.split(',');
}
const query = params.q || '';
if (tags.length === 0 && query === '') {
listings = JSON.parse(container.dataset.listings);
}
if (container.dataset.displayedlisting) {
openedListing = JSON.parse(container.dataset.displayedlisting);
({ slug } = openedListing);
isModalOpen = true;
}
this.debouncedListingSearch = debounceAction(this.handleQuery.bind(this), {
time: 150,
config: { leading: true },
});
this.setState({
query,
tags,
category,
allCategories,
listings,
openedListing,
slug,
isModalOpen,
});
this.listingSearch(query, tags, category, slug);
this.setUser();
/*
The width of the columns also changes when the browser is resized
so we will also call this function on window resize to recalculate
each grid item's height to avoid content overflow
*/
window.addEventListener('resize', resizeAllMasonryItems);
}
componentDidUpdate() {
this.triggerMasonry();
}
addTag = (e, tag) => {
e.preventDefault();
const { query, tags, category } = this.state;
const newTags = tags;
if (newTags.indexOf(tag) === -1) {
newTags.push(tag);
}
this.setState({ tags: newTags, page: 0, isModalOpen: false });
this.listingSearch(query, newTags, category, null);
window.scroll(0, 0);
};
removeTag = (e, tag) => {
e.preventDefault();
const { query, tags, category } = this.state;
const newTags = tags;
const index = newTags.indexOf(tag);
if (newTags.indexOf(tag) > -1) {
newTags.splice(index, 1);
}
this.setState({ tags: newTags, page: 0 });
this.listingSearch(query, newTags, category, null);
};
handleKeyPressedOnSelectedTags = (e, tag) => {
if (e.key === 'Enter') {
this.removeTag(e, tag);
}
};
selectCategory = (e, cat = '') => {
e.preventDefault();
const { query, tags } = this.state;
this.setState({ category: cat, page: 0 });
this.listingSearch(query, tags, cat, null);
};
handleCloseModal = () => {
const { query, tags, category } = this.state;
this.setState({ openedListing: null, page: 0, isModalOpen: false });
this.setLocation(query, tags, category, null);
};
handleOpenModal = (e, listing) => {
e.preventDefault();
this.setState({ openedListing: listing, isModalOpen: true });
window.history.replaceState(
null,
null,
`/listings/${listing.category}/${listing.slug}`,
);
this.setLocation(null, null, listing.category, listing.slug);
};
handleDraftingMessage = (e) => {
e.preventDefault();
this.setState({ message: e.target.value });
};
handleSubmitMessage = (e) => {
e.preventDefault();
const { message, openedListing } = this.state;
if (message.replace(/\s/g, '').length === 0) {
return;
}
const formData = new FormData();
formData.append('user_id', openedListing.user_id);
formData.append('message', `**re: ${openedListing.title}** ${message}`);
formData.append('controller', 'chat_channels');
const destination = `/connect/@${openedListing.author.username}`;
const metaTag = document.querySelector("meta[name='csrf-token']");
window
.fetch('/chat_channels/create_chat', {
method: 'POST',
headers: {
'X-CSRF-Token': metaTag.getAttribute('content'),
},
body: formData,
credentials: 'same-origin',
})
.then(() => {
window.location.href = destination;
})
.catch((error) => {
Honeybadger.notify(error);
});
};
handleQuery = (e) => {
const { tags, category } = this.state;
this.setState({ query: e.target.value, page: 0 });
this.listingSearch(e.target.value, tags, category, null);
};
clearQuery = () => {
const { tags, category } = this.state;
document.getElementById('listings-search').value = '';
this.setState({ query: '', page: 0 });
this.listingSearch('', tags, category, null);
};
loadNextPage = () => {
const { query, tags, category, slug, page } = this.state;
this.setState({ page: page + 1 });
this.listingSearch(query, tags, category, slug);
};
setUser = () => {
const { currentUserId } = this.state;
setTimeout(() => {
if (window.currentUser && currentUserId === null) {
this.setState({ currentUserId: window.currentUser.id });
}
}, 1000);
};
triggerMasonry = () => {
resizeAllMasonryItems();
setTimeout(resizeAllMasonryItems, 1);
setTimeout(resizeAllMasonryItems, 3);
};
setLocation = (query, tags, category, slug) => {
const newLocation = getLocation({ query, tags, category, slug });
window.history.replaceState(null, null, newLocation);
};
/**
* Call search API for Listings
*
* @param {string} query - The search term
* @param {string} tags - The tags selected by the user
* @param {string} category - The category selected by the user
* @param {string} slug - The listing's slug
*
* @returns {Promise} A promise object with response formatted as JSON.
*/
listingSearch(query, tags, category, slug) {
const { page } = this.state;
const dataHash = {
category,
listing_search: query,
page,
per_page: LISTING_PAGE_SIZE,
tags,
tag_boolean_mode: 'all',
};
const responsePromise = fetchSearch('listings', dataHash);
return responsePromise.then((response) => {
const listings = response.result;
const fullListings = updateListings(listings);
this.setState({
listings: fullListings,
initialFetch: false,
showNextPageButton: listings.length === LISTING_PAGE_SIZE,
});
this.setLocation(query, tags, category, slug);
});
}
render() {
const {
listings,
query,
tags,
category,
allCategories,
currentUserId,
openedListing,
showNextPageButton,
initialFetch,
message,
isModalOpen,
} = this.state;
const shouldRenderModal =
isModalOpen && openedListing !== null && openedListing !== undefined;
if (initialFetch) {
this.triggerMasonry();
}
return (
<div className="crayons-layout crayons-layout--2-cols">
<ListingFilters
categories={allCategories}
category={category}
onSelectCategory={this.selectCategory}
message={message}
onKeyUp={this.debouncedListingSearch}
onClearQuery={this.clearQuery}
onRemoveTag={this.removeTag}
tags={tags}
onKeyPress={this.handleKeyPressedOnSelectedTags}
query={query}
/>
<AllListings
listings={listings}
onAddTag={this.addTag}
onChangeCategory={this.selectCategory}
currentUserId={currentUserId}
onOpenModal={this.handleOpenModal}
showNextPageButton={showNextPageButton}
loadNextPage={this.loadNextPage}
/>
{shouldRenderModal && (
<Modal
currentUserId={currentUserId}
onAddTag={this.addTag}
onChangeDraftingMessage={this.handleDraftingMessage}
onClick={this.handleCloseModal}
onChangeCategory={this.selectCategory}
onOpenModal={this.handleOpenModal}
onSubmit={this.handleSubmitMessage}
listing={openedListing}
message={message}
/>
)}
</div>
);
}
}
Listings.displayName = 'Classified Listings';