docbrown/app/javascript/listings/listings.jsx
Mario See fda048dc12 Listing Drafts and Deletion (#3540)
* wip init handle draft class and styling

* add draft button

* start splitting up listing row into multile files

* separate tag links

* split location

* fix location

* separate listing row action buttons

* update listing row proptypes to describe listing object

* contact via connect to jsx

* fix default checked

* init draft creation and first publish charge

* fix first publish logic and credit charging

* handle drafts in dashboard filtering

* adjust isDraft bool statement

* hide drafts from main listings feed

* fix expired and draft bools in listing row

* adjust create listing org credits

* internally handle drafts

* adjust listing row

* break down update method

* remove unnecessary logic and shorten lines

* fix logic again woops

* convert input hidden value + submit into one form button submit with corresponding name and value

also adds clear message that drafts do not cost credits

* handle insufficient org credits on first publish

- uses user credits to publish
- similar to bump implementation

* fix sorting for personal and all orgs

* space out some elements

* don't spend original users credit for them

* add delete buttons and delete_confirm and destroy methods

* remove notification removal since listings dont trigger notifications

* move draft message and add draft_params to tests

* Update classified_listings_controller.rb

* update tests

* update snapshot

* add del tests

* add guard preventing free draft publish

* fix draft filter

* update from master and resolve merge conflict in listing preact

* Revert "update from master and resolve merge conflict in listing preact"

This reverts commit 0a34fccf334a2ca0902644b486cb26ead6bef664.

* update column spec

instead of setting and saving

* separate two expectations into their own tests

* split more tests into single expectations

* change to unless bumped_at? instead of if nil

* Fix listing draft edge cases and styling

* Add title tags to listings pages
2019-09-01 13:54:54 -04:00

534 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { h, Component } from 'preact';
import { SingleListing } from './singleListing';
export class Listings extends Component {
state = {
listings: [],
query: '',
tags: [],
index: null,
category: '',
allCategories: [],
initialFetch: true,
currentUserId: null,
openedListing: null,
message: '',
slug: null,
page: 0,
showNextPageButt: false,
};
componentWillMount() {
const params = this.getQueryParams();
const t = this;
const algoliaId = document.querySelector("meta[name='algolia-public-id']")
.content;
const algoliaKey = document.querySelector("meta[name='algolia-public-key']")
.content;
const env = document.querySelector("meta[name='environment']").content;
const client = algoliasearch(algoliaId, algoliaKey);
const index = client.initIndex(`ClassifiedListing_${env}`);
const container = document.getElementById('classifieds-index-container');
const category = container.dataset.category || '';
const allCategories = JSON.parse(container.dataset.allcategories || []);
let tags = [];
if (params.t) {
tags = params.t.split(',');
}
const query = params.q || '';
let listings = [];
if (tags.length === 0 && query === '') {
listings = JSON.parse(container.dataset.listings);
}
let openedListing = null;
let slug = null;
if (container.dataset.displayedlisting) {
openedListing = JSON.parse(container.dataset.displayedlisting);
slug = openedListing.slug;
document.body.classList.add('modal-open');
}
t.setState({
query,
tags,
index,
category,
allCategories,
listings,
openedListing,
slug,
});
t.listingSearch(query, tags, category, slug);
t.setUser();
document.body.addEventListener('keydown', t.handleKeyDown);
/*
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();
}
componentWillUnmount() {
document.body.removeEventListener('keydown', this.handleKeyDown);
}
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, listings: [] });
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, listings: [] });
this.listingSearch(query, newTags, category, null);
};
selectCategory = (e, cat) => {
e.preventDefault();
const { query, tags } = this.state;
this.setState({ category: cat, page: 0, listings: [] });
this.listingSearch(query, tags, cat, null);
};
handleKeyDown = e => {
// Enable Escape key to close an open listing.
this.handleCloseModal(e);
};
handleCloseModal = e => {
const { openedListing } = this.state;
if (
(openedListing !== null && e.key === 'Escape') ||
e.target.id === 'single-classified-listing-container__inner' ||
e.target.id === 'classified-filters' ||
e.target.id === 'classified-listings-modal-background'
) {
const { query, tags, category } = this.state;
this.setState({ openedListing: null, page: 0 });
this.setLocation(query, tags, category, null);
document.body.classList.remove('modal-open');
}
};
handleOpenModal = (e, listing) => {
e.preventDefault();
this.setState({ openedListing: listing });
window.history.replaceState(
null,
null,
`/listings/${listing.category}/${listing.slug}`,
);
this.setLocation(null, null, listing.category, listing.slug);
document.body.classList.add('modal-open');
};
handleDraftingMessage = e => {
e.preventDefault();
this.setState({ message: e.target.value });
};
handleSubmitMessage = e => {
e.preventDefault();
const { message, openedListing } = this.state;
if (this.state.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;
});
};
handleQuery = e => {
const { tags, category } = this.state;
this.setState({ query: e.target.value, page: 0, listings: [] });
this.listingSearch(e.target.value, tags, category, null);
};
clearQuery = () => {
const { tags, category } = this.state;
document.getElementById('listings-search').value = '';
this.setState({ query: '', page: 0, listings: [] });
this.listingSearch('', tags, category, null);
};
getQueryParams = () => {
let qs = document.location.search;
qs = qs.split('+').join(' ');
const params = {};
let tokens;
const re = /[?&]?([^=]+)=([^&]*)/g;
// eslint-disable-next-line no-cond-assign
while ((tokens = re.exec(qs))) {
params[decodeURIComponent(tokens[1])] = decodeURIComponent(tokens[2]);
}
return params;
};
loadNextPage = () => {
const { query, tags, category, slug, page } = this.state;
this.setState({ page: page + 1 });
this.listingSearch(query, tags, category, slug);
};
setUser = () => {
const t = this;
setTimeout(function() {
if (window.currentUser && t.state.currentUserId === null) {
t.setState({ currentUserId: window.currentUser.id });
}
}, 300);
setTimeout(function() {
if (window.currentUser && t.state.currentUserId === null) {
t.setState({ currentUserId: window.currentUser.id });
}
}, 1000);
};
triggerMasonry = () => {
resizeAllMasonryItems();
setTimeout(function() {
resizeAllMasonryItems();
}, 1);
setTimeout(function() {
resizeAllMasonryItems();
}, 3);
};
setLocation = (query, tags, category, slug) => {
let newLocation = '';
if (slug) {
newLocation = `/listings/${category}/${slug}`;
} else if (query.length > 0 && tags.length > 0) {
newLocation = `/listings/${category}?q=${query}&t=${tags}`;
} else if (query.length > 0) {
newLocation = `/listings/${category}?q=${query}`;
} else if (tags.length > 0) {
newLocation = `/listings/${category}?t=${tags}`;
} else if (category.length > 0) {
newLocation = `/listings/${category}`;
} else {
newLocation = '/listings';
}
window.history.replaceState(null, null, newLocation);
};
listingSearch(query, tags, category, slug) {
const t = this;
const { index, page, listings } = t.state;
const filterObject = { tagFilters: tags, hitsPerPage: 75, page };
if (category.length > 0) {
filterObject.filters = `category:${category}`;
}
index.search(query, filterObject).then(function searchDone(content) {
const fullListings = listings;
content.hits.forEach(listing => {
if (listing.bumped_at) {
if (!listings.map(l => l.id).includes(listing.id)) {
fullListings.push(listing);
}
}
});
t.setState({
listings: fullListings,
initialFetch: false,
showNextPageButt: content.hits.length === 75,
});
});
this.setLocation(query, tags, category, slug);
}
render() {
const {
listings,
query,
tags,
category,
allCategories,
currentUserId,
openedListing,
showNextPageButt,
initialFetch,
} = this.state;
const allListings = listings.map(listing => (
<SingleListing
onAddTag={this.addTag}
onChangeCategory={this.selectCategory}
listing={listing}
currentUserId={currentUserId}
onOpenModal={this.handleOpenModal}
isOpen={false}
/>
));
const selectedTags = tags.map(tag => (
<span className="classified-tag">
<a
href="/listings?tags="
className="tag-name"
onClick={e => this.removeTag(e, tag)}
data-no-instant
>
<span>{tag}</span>
<span
className="tag-close"
onClick={e => this.removeTag(e, tag)}
data-no-instant
>
×
</span>
</a>
</span>
));
const categoryLinks = allCategories.map(cat => (
<a
href={`/listings/${cat.slug}`}
className={cat.slug === category ? 'selected' : ''}
onClick={e => this.selectCategory(e, cat.slug)}
data-no-instant
>
{cat.name}
</a>
));
let nextPageButt = '';
if (showNextPageButt) {
nextPageButt = (
<div className="classifieds-load-more-button">
<button onClick={e => this.loadNextPage(e)} type="button">
Load More Listings
</button>
</div>
);
}
const clearQueryButton =
query.length > 0 ? (
<button
type="button"
className="classified-search-clear"
onClick={this.clearQuery}
>
×
</button>
) : (
''
);
let modal = '';
let modalBg = '';
let messageModal = '';
if (openedListing) {
modalBg = (
<div
className="classified-listings-modal-background"
onClick={this.handleCloseModal}
role="presentation"
id="classified-listings-modal-background"
/>
);
if (
openedListing.contact_via_connect &&
openedListing.user_id !== currentUserId
) {
messageModal = (
<form
id="listings-message-form"
className="listings-contact-via-connect"
onSubmit={this.handleSubmitMessage}
>
<p>
<b>Contact {openedListing.author.name} via DEV Connect</b>
</p>
<textarea
value={this.state.message}
onChange={this.handleDraftingMessage}
id="new-message"
rows="4"
cols="70"
placeholder="Enter your message here..."
/>
<button type="submit" value="Submit" className="submit-button cta">
SEND
</button>
<p>
<em>
Message must be relevant and on-topic with the listing. All
private interactions <b>must</b> abide by the{' '}
<a href="/code-of-conduct">code of conduct</a>
</em>
</p>
</form>
);
} else if (openedListing.contact_via_connect) {
messageModal = (
<form
id="listings-message-form"
className="listings-contact-via-connect"
>
<p>
This is your active listing. Any member can contact you via this
form.
</p>
<textarea
value={this.state.message}
onChange={this.handleDraftingMessage}
id="new-message"
rows="4"
cols="70"
placeholder="Enter your message here..."
/>
<button type="submit" value="Submit" className="submit-button cta">
SEND
</button>
<p>
<em>
All private interactions <b>must</b> abide by the{' '}
<a href="/code-of-conduct">code of conduct</a>
</em>
</p>
</form>
);
}
modal = (
<div className="single-classified-listing-container">
<div
id="single-classified-listing-container__inner"
className="single-classified-listing-container__inner"
onClick={this.handleCloseModal}
>
<SingleListing
onAddTag={this.addTag}
onChangeCategory={this.selectCategory}
listing={openedListing}
currentUserId={currentUserId}
onOpenModal={this.handleOpenModal}
isOpen
/>
{messageModal}
<a
href="/about-listings"
className="single-classified-listing-info-link"
>
About DEV Listings
</a>
<div className="single-classified-listing-container__spacer" />
</div>
</div>
);
}
if (initialFetch) {
this.triggerMasonry();
}
return (
<div className="listings__container">
{modalBg}
<div className="classified-filters" id="classified-filters">
<div className="classified-filters-categories">
<a
href="/listings"
className={category === '' ? 'selected' : ''}
onClick={e => this.selectCategory(e, '')}
data-no-instant
>
all
</a>
{categoryLinks}
<a href="/listings/new" className="classified-create-link">
Create a Listing
</a>
<a href="/listings/dashboard" className="classified-create-link">
Manage Listings
</a>
</div>
<div className="classified-filters-tags" id="classified-filters-tags">
<input
type="text"
placeholder="search"
id="listings-search"
autoComplete="off"
defaultValue={query}
onKeyUp={e => this.handleQuery(e)}
/>
{clearQueryButton}
{selectedTags}
</div>
</div>
<div className="classifieds-columns" id="classified-listings-results">
{allListings}
</div>
{nextPageButt}
{modal}
</div>
);
}
}
function resizeMasonryItem(item) {
/* Get the grid object, its row-gap, and the size of its implicit rows */
const grid = document.getElementsByClassName('classifieds-columns')[0];
const rowGap = parseInt(
window.getComputedStyle(grid).getPropertyValue('grid-row-gap'),
);
const rowHeight = parseInt(
window.getComputedStyle(grid).getPropertyValue('grid-auto-rows'),
);
const rowSpan = Math.ceil(
(item.querySelector('.listing-content').getBoundingClientRect().height +
rowGap) /
(rowHeight + rowGap),
);
/* Set the spanning as calculated above (S) */
// eslint-disable-next-line no-param-reassign
item.style.gridRowEnd = `span ${rowSpan}`;
}
function resizeAllMasonryItems() {
// Get all item class objects in one list
const allItems = document.getElementsByClassName('single-classified-listing');
/*
* Loop through the above list and execute the spanning function to
* each list-item (i.e. each masonry item)
*/
// eslint-disable-next-line vars-on-top
for (let i = 0; i < allItems.length; i++) {
resizeMasonryItem(allItems[i]);
}
}
Listings.displayName = 'Classified Listings';