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
This commit is contained in:
Mario See 2019-09-01 13:54:54 -04:00 committed by Ben Halpern
parent 3cf6a2d4bc
commit fda048dc12
29 changed files with 726 additions and 232 deletions

View file

@ -6,6 +6,9 @@
text-align: left;
max-width: 100%;
min-height: 100vh;
&.classifieds-form-container {
margin: 90px auto 150px;
}
@media screen and (min-width: 950px) {
margin: 40px auto;
}
@ -143,7 +146,7 @@
);
@media screen and (min-width: 580px) {
border-radius: 8px;
margin: 100px auto 20px;
margin: 20px auto 20px;
}
* {
box-sizing: border-box;
@ -268,6 +271,10 @@
theme-container-box-shadow,
2px 2px 8px darken($light-medium-gray, 5%)
);
&.cta-draft {
color: $black;
background: $yellow;
}
}
.listings-current-credits {
position: fixed;
@ -611,7 +618,9 @@ form.listings-contact-via-connect {
margin-right: 5px;
display: inline-block;
width: fit-content;
cursor: pointer;
margin-bottom: 10px;
user-select: none;
@include themeable(
color,
theme-color,
@ -740,9 +749,15 @@ form.listings-contact-via-connect {
border-radius: 3px;
position: relative;
background: var(--theme-container-background, #fff);
&.draft {
background: var(--theme-container-accent-background, #fffeec)
}
&.expired {
opacity: 0.7;
}
.listing-org {
@include themeable(
background,
@ -824,6 +839,11 @@ form.listings-contact-via-connect {
background: $green;
color: $black;
}
&.yellow {
background: $yellow;
color: $black;
}
&.red {
background: $red;

View file

@ -143,7 +143,7 @@
justify-content: space-between;
align-items: center;
@media screen and (max-width: 426px) {
@media screen and (max-width: 600px) {
flex-direction: column;
align-items: stretch;
}
@ -152,7 +152,7 @@
display: inline-block;
padding: 14px;
@media screen and (max-width: 426px) {
@media screen and (max-width: 600px) {
margin-bottom: 15px;
text-align: center;
}

View file

@ -1,26 +0,0 @@
@import 'variables';
.delete-confirm{
padding:150px 10px;
min-height:300px;
h4{
background:$black;
padding:5px 8px;
color:white;
display:inline-block;
margin-bottom:0px;
}
h2{
font-size:22px;
margin-bottom:40px;
}
a.delete-link{
background:$red;
color:white;
padding:10px 15px;
&:hover{
color:white;
opacity:0.9;
}
}
}

View file

@ -20,7 +20,6 @@
@import 'signup-modal';
@import 'tags';
@import 'live';
@import 'delete-confirm';
@import 'preact/sidebar-widget';
@import 'preact/article-form';
@import 'tag-edit';

View file

@ -221,3 +221,28 @@ body.comic-sans-article-body {
body.pro-status-true .pro-visible-block {
display: block !important;;
}
.delete-confirm{
padding:150px 10px;
min-height:300px;
h4{
background:$black;
padding:5px 8px;
color:white;
display:inline-block;
margin-bottom:0px;
}
h2{
font-size:22px;
margin-bottom:40px;
}
a.delete-link{
background:$red;
color:white;
padding:10px 15px;
&:hover{
color:white;
opacity:0.9;
}
}
}

View file

@ -1,6 +1,6 @@
class ClassifiedListingsController < ApplicationController
include ClassifiedListingsToolkit
before_action :set_classified_listing, only: %i[edit update]
before_action :set_classified_listing, only: %i[edit update destroy]
before_action :set_cache_control_headers, only: %i[index]
before_action :raise_banned, only: %i[new create update]
after_action :verify_authorized, only: %i[edit update]
@ -45,9 +45,21 @@ class ClassifiedListingsController < ApplicationController
@classified_listing.user_id = current_user.id
cost = ClassifiedListing.cost_by_category(@classified_listing.category)
org = Organization.find_by(id: @classified_listing.organization_id)
if listing_params[:action] == "draft"
@classified_listing.published = false
if @classified_listing.save
redirect_to "/listings/dashboard"
else
@credits = current_user.credits.unspent
@classified_listing.cached_tag_list = listing_params[:tag_list]
@organizations = current_user.organizations
render :new
end
return
end
available_org_credits = org.credits.unspent if org
available_user_credits = current_user.credits.unspent
@ -64,27 +76,23 @@ class ClassifiedListingsController < ApplicationController
def update
authorize @classified_listing
cost = ClassifiedListing.cost_by_category(@classified_listing.category)
# NOTE: this should probably be split in three different actions: bump, unpublish, publish
if listing_params[:action] == "bump"
cost = ClassifiedListing.cost_by_category(@classified_listing.category)
return bump_listing(cost) if listing_params[:action] == "bump"
org = Organization.find_by(id: @classified_listing.organization_id)
available_org_credits = org.credits.unspent if org
available_user_credits = current_user.credits.unspent
if org && available_org_credits.size >= cost
charge_credits_before_bump(org, cost)
elsif available_user_credits.size >= cost
charge_credits_before_bump(current_user, cost)
else
redirect_to(credits_path, notice: "Not enough available credits") && return
end
elsif listing_params[:action] == "unpublish"
if listing_params[:action] == "unpublish"
unpublish_listing
redirect_to "/listings/dashboard"
return
elsif listing_params[:action] == "publish"
unless @classified_listing.bumped_at?
first_publish(cost)
return
end
publish_listing
elsif listing_params[:body_markdown].present? && @classified_listing.bumped_at > 24.hours.ago
elsif listing_params[:body_markdown].present? && ((@classified_listing.bumped_at && @classified_listing.bumped_at > 24.hours.ago) || !@classified_listing.published)
update_listing_details
end
@ -104,6 +112,17 @@ class ClassifiedListingsController < ApplicationController
@user_credits = current_user.unspent_credits_count
end
def delete_confirm
@classified_listing = ClassifiedListing.find_by(slug: params[:slug])
authorize @classified_listing
end
def destroy
authorize @classified_listing
@classified_listing.destroy!
redirect_to "/listings/dashboard", notice: "Listing was successfully deleted."
end
private
def create_listing(purchaser, cost)
@ -140,6 +159,37 @@ class ClassifiedListingsController < ApplicationController
end
end
def first_publish(cost)
available_author_credits = @classified_listing.author.credits.unspent
available_user_credits = []
if @classified_listing.author.is_a?(Organization)
available_user_credits = current_user.credits.unspent
end
if available_author_credits.size >= cost
create_listing(@classified_listing.author, cost)
elsif available_user_credits.size >= cost
create_listing(current_user, cost)
else
redirect_to credits_path, notice: "Not enough available credits"
end
end
def bump_listing(cost)
org = Organization.find_by(id: @classified_listing.organization_id)
available_org_credits = org.credits.unspent if org
available_user_credits = current_user.credits.unspent
if org && available_org_credits.size >= cost
charge_credits_before_bump(org, cost)
elsif available_user_credits.size >= cost
charge_credits_before_bump(current_user, cost)
else
redirect_to(credits_path, notice: "Not enough available credits") && return
end
end
def charge_credits_before_bump(purchaser, cost)
ActiveRecord::Base.transaction do
Credits::Buyer.call(
@ -148,7 +198,7 @@ class ClassifiedListingsController < ApplicationController
cost: cost,
)
raise ActiveRecord::Rollback unless bump_listing
raise ActiveRecord::Rollback unless bump_listing_success
end
end

View file

@ -24,7 +24,7 @@ module ClassifiedListingsToolkit
@classified_listing.save
end
def bump_listing
def bump_listing_success
@classified_listing.bumped_at = Time.current
saved = @classified_listing.save
@classified_listing.index! if saved

View file

@ -63,6 +63,14 @@ preact-render-spy (1 nodes)
>
Active
</span>
<span
onClick={[Function onClick]}
class="rounded-btn "
role="button"
tabIndex="0"
>
Draft
</span>
<span
onClick={[Function onClick]}
class="rounded-btn "
@ -72,7 +80,7 @@ preact-render-spy (1 nodes)
Expired
</span>
</div>
<select onChange={[Function sortListings]}>
<select onChange={[Function onChange]}>
<option
value="created_at"
selected="selected"
@ -83,28 +91,47 @@ preact-render-spy (1 nodes)
</select>
</div>
<div class="dashboard-listings-view">
<div class="dashboard-listing-row expired">
<span class="listing-org">Infotrode</span>
<a href="cfp/hehhehe-5hld">
<h2>hehhehe (expired)</h2>
<div class="dashboard-listing-row expired">
<a href="23/edit">
<h2>asdfasdf (expired)</h2>
</a>
<span class="dashboard-listing-date">Jun 11</span>
<span class="dashboard-listing-category">
<a href="/listings/cfp/">cfp</a>
</span>
<span class="dashboard-listing-tags"></span>
<span class="dashboard-listing-tags">
<a
href="/listings?t=computerscience"
data-no-instant={true}
>
#computerscience
</a>
<a
href="/listings?t=career"
data-no-instant={true}
>
#career
</a>
</span>
<div class="listing-row-actions">
<a
href="/listings/25/edit"
href="/listings/23/edit"
class="dashboard-listing-edit-button cta pill green"
>
EDIT
</a>
<a
href="/listings/cfp/asdfasdf-2ea8/delete_confirm"
class="dashboard-listing-delete-button cta pill black"
data-no-instant={true}
>
DELETE
</a>
</div>
</div>
<div class="dashboard-listing-row expired">
<div class="dashboard-listing-row expired">
<span class="listing-org">Yoyodyne</span>
<a href="events/yoyoyoyoyoooooooo-4jcb">
<a href="24/edit">
<h2>YOYOYOYOYOOOOOOOO (expired)</h2>
</a>
<span class="dashboard-listing-date">Jun 11</span>
@ -138,40 +165,42 @@ preact-render-spy (1 nodes)
>
EDIT
</a>
<a
href="/listings/events/yoyoyoyoyoooooooo-4jcb/delete_confirm"
class="dashboard-listing-delete-button cta pill black"
data-no-instant={true}
>
DELETE
</a>
</div>
</div>
<div class="dashboard-listing-row expired">
<a href="cfp/asdfasdf-2ea8">
<h2>asdfasdf (expired)</h2>
<div class="dashboard-listing-row expired">
<span class="listing-org">Infotrode</span>
<a href="25/edit">
<h2>hehhehe (expired)</h2>
</a>
<span class="dashboard-listing-date">Jun 11</span>
<span class="dashboard-listing-category">
<a href="/listings/cfp/">cfp</a>
</span>
<span class="dashboard-listing-tags">
<a
href="/listings?t=computerscience"
data-no-instant={true}
>
#computerscience
</a>
<a
href="/listings?t=career"
data-no-instant={true}
>
#career
</a>
</span>
<span class="dashboard-listing-tags"></span>
<div class="listing-row-actions">
<a
href="/listings/23/edit"
href="/listings/25/edit"
class="dashboard-listing-edit-button cta pill green"
>
EDIT
</a>
<a
href="/listings/cfp/hehhehe-5hld/delete_confirm"
class="dashboard-listing-delete-button cta pill black"
data-no-instant={true}
>
DELETE
</a>
</div>
</div>
</div>
</div>
`;
`;

View file

@ -1,72 +1,77 @@
import PropTypes from 'prop-types';
import { h } from 'preact';
import ListingDate from './rowElements/listingDate';
import Tags from './rowElements/tags';
import Location from './rowElements/location';
import ActionButtons from './rowElements/actionButtons';
export const ListingRow = ({ listing }) => {
const tagLinks = listing.tag_list.map(tag => (
<a href={`/listings?t=${tag}`} data-no-instant>
#{tag}{' '}
</a>
));
const bumpedAt = listing.bumped_at ? listing.bumped_at.toString() : null;
const isExpired =
bumpedAt && !listing.published
? (Date.now() - new Date(bumpedAt).getTime()) / (1000 * 60 * 60 * 24) > 30
: false;
const isDraft = bumpedAt ? !isExpired && !listing.published : true;
const listingUrl = listing.published
? `${listing.category}/${listing.slug}`
: `${listing.id}/edit`;
const listingLocation = listing.location ? (`${listing.location}`) : '';
const listingDate = listing.bumped_at
? new Date(listing.bumped_at.toString()).toLocaleDateString('default', {
const expiryDate = listing.expires_at
? new Date(listing.expires_at.toString()).toLocaleDateString('default', {
day: '2-digit',
month: 'short',
})
: new Date(listing.updated_at.toString()).toLocaleDateString('default', {
day: '2-digit',
month: 'short',
});
: '';
const orgName = listing.organization_id ? (
<span className="listing-org">{listing.author.name}</span>
) : (
''
);
const expiryDate = listing.expires_at ?
new Date(listing.expires_at.toString()).toLocaleDateString('default', {
day: '2-digit',
month: 'short',
}) : '' ;
const listingExpiry = expiryDate !== '' ? (
` | Expires on: ${expiryDate}`
) : (
''
);
const listingExpiry = expiryDate !== '' ? ` | Expires on: ${expiryDate}` : '';
return (
<div className={`dashboard-listing-row ${listing.published ? '' : 'expired'}`}>
{orgName}
<a href={`${listing.category}/${listing.slug}`}>
<h2>{listing.title + (listing.published ? '' : " (expired)")}</h2>
<div
className={`dashboard-listing-row ${isDraft ? 'draft' : ''} ${
isExpired ? 'expired' : ''
}`}
>
{listing.organization_id && (
<span className="listing-org">{listing.author.name}</span>
)}
<a href={listingUrl}>
<h2>{listing.title + (isExpired ? ' (expired)' : '')}</h2>
</a>
<span className="dashboard-listing-date">
{listingDate}
{listingExpiry}
{listingLocation}
</span>
<ListingDate
bumpedAt={listing.bumped_at}
updatedAt={listing.updated_at}
/>
{listingExpiry}
{listing.location && <Location location={listing.location} />}
<span className="dashboard-listing-category">
<a href={`/listings/${listing.category}/`}>{listing.category}</a>
</span>
<span className="dashboard-listing-tags">{tagLinks}</span>
<div className="listing-row-actions">
{/* <a className="dashboard-listing-bump-button cta pill black">BUMP</a> */}
<a
href={`/listings/${listing.id}/edit`}
className="dashboard-listing-edit-button cta pill green"
>
EDIT
</a>
{/* <a className="dashboard-listing-delete-button cta pill red">DELETE</a> */}
</div>
<Tags tagList={listing.tag_list} />
<ActionButtons
isDraft={isDraft}
listingUrl={`${listing.category}/${listing.slug}`}
editUrl={`/listings/${listing.id}/edit`}
deleteConfirmUrl={`/listings/${listing.category}/${listing.slug}/delete_confirm`}
/>
</div>
);
};
ListingRow.propTypes = {
listing: PropTypes.object.isRequired,
listing: PropTypes.PropTypes.shape({
title: PropTypes.string.isRequired,
tag_list: PropTypes.arrayOf(PropTypes.string),
created_at: PropTypes.instanceOf(Date),
bumped_at: PropTypes.instanceOf(Date),
updated_at: PropTypes.instanceOf(Date),
category: PropTypes.string.isRequired,
id: PropTypes.number.isRequired,
user_id: PropTypes.number.isRequired,
slug: PropTypes.string.isRequired,
organization_id: PropTypes.number,
location: PropTypes.string,
expires_at: PropTypes.bool,
published: PropTypes.bool.isRequired,
author: PropTypes.object,
}).isRequired,
};

View file

@ -0,0 +1,39 @@
import PropTypes from 'prop-types';
import { h } from 'preact';
const ActionButtons = ({ isDraft, editUrl, deleteConfirmUrl }) => {
return (
<div className="listing-row-actions">
{/* <a className="dashboard-listing-bump-button cta pill black">BUMP</a> */}
{isDraft && (
<a
href={editUrl}
className="dashboard-listing-edit-button cta pill yellow"
>
DRAFT
</a>
)}
<a
href={editUrl}
className="dashboard-listing-edit-button cta pill green"
>
EDIT
</a>
<a
href={deleteConfirmUrl}
className="dashboard-listing-delete-button cta pill black"
data-no-instant
>
DELETE
</a>
</div>
);
};
ActionButtons.propTypes = {
isDraft: PropTypes.bool.isRequired,
editUrl: PropTypes.string.isRequired,
deleteConfirmUrl: PropTypes.string.isRequired,
};
export default ActionButtons;

View file

@ -0,0 +1,28 @@
import PropTypes from 'prop-types';
import { h } from 'preact';
const ListingDate = ({ bumpedAt, updatedAt }) => {
const listingDate = bumpedAt
? new Date(bumpedAt.toString()).toLocaleDateString('default', {
day: '2-digit',
month: 'short',
})
: new Date(updatedAt.toString()).toLocaleDateString('default', {
day: '2-digit',
month: 'short',
});
return (
<span className="dashboard-listing-date">
{listingDate}
</span>
)
}
ListingDate.propTypes = {
bumpedAt: PropTypes.instanceOf(Date).isRequired,
updatedAt: PropTypes.instanceOf(Date).isRequired,
}
export default ListingDate;

View file

@ -0,0 +1,17 @@
import PropTypes from 'prop-types';
import { h } from 'preact';
const Location = ({ location }) => {
return (
<span className="dashboard-listing-date">
{location}
</span>
)
}
Location.propTypes = {
location: PropTypes.string.isRequired,
}
export default Location;

View file

@ -0,0 +1,25 @@
import PropTypes from 'prop-types';
import { h } from 'preact';
const Tags = ({ tagList }) => {
const tagLinks = tagList.map(tag => (
<a href={`/listings?t=${tag}`} data-no-instant>
#
{tag}
{' '}
</a>
));
return (
<span className="dashboard-listing-tags">
{tagLinks}
</span>
)
}
Tags.propTypes = {
tagList: PropTypes.arrayOf(PropTypes.string).isRequired,
}
export default Tags;

View file

@ -0,0 +1,25 @@
import { h } from 'preact';
import PropTypes from 'prop-types';
const ContactViaConnect = ({ onChange, checked }) => (
<div className="field">
<label className="listingform__label" htmlFor="contact_via_connect">
Allow Users to Message Me Via In-App Chat (DEV Connect)
</label>
<input
type="checkbox"
className="listingform__input listingform__contact_via_connect"
id="contact_via_connect"
name="classified_listing[contact_via_connect]"
onInput={onChange}
checked
/>
</div>
)
ContactViaConnect.propTypes = {
onChange: PropTypes.func.isRequired,
checked: PropTypes.bool.isRequired,
}
export default ContactViaConnect;

View file

@ -9,6 +9,7 @@ export class ListingDashboard extends Component {
userCredits: 0,
selectedListings: 'user',
filter: 'All',
sort: 'created_at',
};
componentDidMount() {
@ -32,24 +33,43 @@ export class ListingDashboard extends Component {
orgs,
selectedListings,
filter,
sort
} = this.state;
const isExpired = (listing) => listing.bumped_at && (!listing.published) ? ((Date.now() - new Date(listing.bumped_at.toString()).getTime()) / (1000 * 60 * 60 * 24)) > 30 : false;
const isDraft = (listing) => listing.bumped_at ? !isExpired(listing) && (!listing.published) : true;
const filterListings = (listingsToFilter, selectedFilter) => {
if (selectedFilter === "Expired") {
return listingsToFilter.filter(listing => listing.published === false)
if (selectedFilter === "Draft") {
return listingsToFilter.filter(listing => isDraft(listing))
} if (selectedFilter === "Expired") {
return listingsToFilter.filter(listing => isExpired(listing))
} if (selectedFilter === "Active") {
return listingsToFilter.filter(listing => listing.published === true)
}
return listingsToFilter
}
const customSort = (a,b) => {
if (a[sort] === null) {
return 1
}
if (b[sort] === null) {
return -1
}
if (a[sort] > b[sort]) {
return -1
}
return 1
}
const showListings = (selected, userListings, organizationListings, selectedFilter) => {
let displayedListings;
if (selected === 'user') {
displayedListings = filterListings(userListings, selectedFilter)
displayedListings = filterListings(userListings, selectedFilter).sort(customSort)
return displayedListings.map(listing => <ListingRow listing={listing} />)
}
displayedListings = filterListings(organizationListings, selectedFilter)
displayedListings = filterListings(organizationListings, selectedFilter).sort(customSort)
return displayedListings.map(listing =>
listing.organization_id === selected ? (
<ListingRow listing={listing} />
@ -58,13 +78,8 @@ export class ListingDashboard extends Component {
),
);
};
const sortListings = (event) => {
const sortedListings = listings.sort((a,b) => (a[event.target.value] > b[event.target.value]) ? -1 : 1)
this.setState({listings: sortedListings});
}
const filters = ["All", "Active", "Expired"];
const filters = ["All", "Active", "Draft", "Expired"];
const filterButtons = filters.map(f => (
<span
onClick={(event) => {this.setState( {filter:event.target.textContent} )}}
@ -80,7 +95,7 @@ export class ListingDashboard extends Component {
<div className="listings-dashboard-filter-buttons">
{filterButtons}
</div>
<select onChange={sortListings} >
<select onChange={(event) => {this.setState({sort: event.target.value})}}>
<option value="created_at" selected="selected">Recently Created</option>
<option value="bumped_at">Recently Bumped</option>
</select>
@ -100,10 +115,15 @@ export class ListingDashboard extends Component {
const listingLength = (selected, userListings, organizationListings) => {
return selected === 'user' ? (
<h4>Listings Made: {userListings.length}</h4>
<h4>
Listings Made:
{' '}
{userListings.length}
</h4>
) : (
<h4>
Listings Made:{' '}
Listings Made:
{' '}
{
organizationListings.filter(
listing => listing.organization_id === selected,
@ -115,10 +135,15 @@ export class ListingDashboard extends Component {
const creditCount = (selected, userCreds, organizations) => {
return selected === 'user' ? (
<h4>Credits Available: {userCredits}</h4>
<h4>
Credits Available:
{' '}
{userCreds}
</h4>
) : (
<h4>
Credits Available:{' '}
Credits Available:
{' '}
{organizations.find(org => org.id === selected).unspent_credits_count}
</h4>
);

View file

@ -5,6 +5,7 @@ import BodyMarkdown from './elements/bodyMarkdown';
import Categories from './elements/categories';
import Tags from './elements/tags';
import OrgSettings from './elements/orgSettings';
import ContactViaConnect from './elements/contactViaConnect';
import ExpireDate from './elements/expireDate';
export default class ListingForm extends Component {
@ -29,6 +30,7 @@ export default class ListingForm extends Component {
categoriesForDetails: this.categoriesForDetails,
organizations,
organizationId: null, // change this for /edit later
contactViaConnect: this.listing.contact_via_connect || 'checked',
expireDate: this.listing.expires_at || '',
};
}
@ -49,40 +51,22 @@ export default class ListingForm extends Component {
categoriesForSelect,
organizations,
organizationId,
contactViaConnect,
expireDate,
} = this.state;
const orgArea =
organizations && organizations.length > 0 ? (
<OrgSettings
organizations={organizations}
organizationId={organizationId}
onToggle={this.handleOrgIdChange}
/>
) : (
''
);
const selectOrg = ((organizations && organizations.length > 0) ? <OrgSettings organizations={organizations} organizationId={organizationId} onToggle={this.handleOrgIdChange} /> : '');
if (id === null) {
return (
<div>
<Title defaultValue={title} onChange={linkState(this, 'title')} />
<BodyMarkdown
defaultValue={bodyMarkdown}
onChange={linkState(this, 'bodyMarkdown')}
/>
<Categories
categoriesForSelect={categoriesForSelect}
categoriesForDetails={categoriesForDetails}
onChange={linkState(this, 'category')}
category={category}
/>
<Tags
defaultValue={tagList}
category={category}
onInput={linkState(this, 'tagList')}
/>
<BodyMarkdown defaultValue={bodyMarkdown} onChange={linkState(this, 'bodyMarkdown')} />
<Categories categoriesForSelect={categoriesForSelect} categoriesForDetails={categoriesForDetails} onChange={linkState(this, 'category')} category={category} />
<Tags defaultValue={tagList} category={category} onInput={linkState(this, 'tagList')} />
<ExpireDate defaultValue={expireDate} onChange={linkState(this, 'expireDate')} />
{orgArea}
{/* add contact via connect checkbox later */}
{selectOrg}
<ContactViaConnect defaultValue={contactViaConnect} onChange={linkState(this, 'contactViaConnect')} />
</div>
);
}
@ -95,8 +79,8 @@ export default class ListingForm extends Component {
onChange={linkState(this, 'bodyMarkdown')}
/>
<Tags defaultValue={tagList} onInput={linkState(this, 'tagList')} />
{orgArea}
{/* add contact via connect checkbox later */}
{selectOrg}
<ContactViaConnect checked={contactViaConnect} onChange={linkState(this, 'contactViaConnect')} />
</div>
);
}

View file

@ -260,8 +260,10 @@ export class Listings extends Component {
index.search(query, filterObject).then(function searchDone(content) {
const fullListings = listings;
content.hits.forEach(listing => {
if (!listings.map(l => l.id).includes(listing.id)) {
fullListings.push(listing);
if (listing.bumped_at) {
if (!listings.map(l => l.id).includes(listing.id)) {
fullListings.push(listing);
}
}
});
t.setState({

View file

@ -80,6 +80,10 @@ class ClassifiedListing < ApplicationRecord
"/listings/#{category}/#{slug}"
end
def natural_expiration_date
(bumped_at || created_at) + 30.days
end
private
def evaluate_markdown

View file

@ -11,6 +11,14 @@ class ClassifiedListingPolicy < ApplicationPolicy
user.org_member?(record.organization_id)
end
def delete_confirm?
update?
end
def destroy?
update?
end
private
def user_is_author?

View file

@ -15,11 +15,10 @@
</div>
<% end %>
<div id="listingform-data"
data-listing="<%= classified_listing.to_json(only: %i[id title body_markdown category cached_tag_list]) %>"
data-organizations="<%= @organizations.to_json(only: %i[id name]) %>"
data-categories-for-select="<%= ClassifiedListing.select_options_for_categories.to_json %>"
data-categories-for-details="<%= ClassifiedListing.categories_available.transform_values { |value_hash| value_hash.except(:cost) }.values.to_json %>"
>
data-listing="<%= classified_listing.to_json(only: %i[id title body_markdown category cached_tag_list]) %>"
data-organizations="<%= @organizations.to_json(only: %i[id name]) %>"
data-categories-for-select="<%= ClassifiedListing.select_options_for_categories.to_json %>"
data-categories-for-details="<%= ClassifiedListing.categories_available.transform_values { |value_hash| value_hash.except(:cost) }.values.to_json %>">
<div style="height: 745px;">
<div class="field">
<%= form.label "title" %>
@ -59,13 +58,13 @@
<%= form.label "location", "Location (If applicable for events, etc.)" %>
<%= form.text_field "location", placeholder: "32 characters max, plain text" %>
</div>
<div class="field">
<%= form.label "contact_via_connect", "Allow Users to Message Me Via In-App Chat (DEV Connect)" %>
<%= form.check_box "contact_via_connect", checked: true %>
</div>
<div class="actions">
<br />
<p>You will not be charged credits to save a draft.</p>
<%= form.button "SAVE DRAFT", type: "submit", name: "classified_listing[action]", class: "cta cta-main-listing-form cta-draft", value: "draft" %>
<% if @credits.size > 0 || (@organizations.present? && @organizations.sum(:unspent_credits_count) > 0) %>
<%= form.submit "CREATE LISTING", class: "cta cta-main-listing-form" %>
<%= form.submit "PUBLISH LISTING", class: "cta cta-main-listing-form" %>
<% else %>
<p>You need at least one credit to create a listing.</p>
<a href="/credits/new" class="cta cta-main-listing-form" data-no-instant>Purchase Credits Now</a>
@ -84,10 +83,10 @@
<div class="listings-current-credits-inner">
<select id="org-credits-select" class="org-credits-select">
<% @organizations.each do |org| %>
<option value="<%= org.id %>" data-credits="<%= org.unspent_credits_count %>"><%= org.name %></option>
<option value="<%= org.id %>" data-credits="<%= org.credits.unspent.size %>"><%= org.name %></option>
<% end %>
</select>
has <span id="org-credits-number"><%= @organizations.first.unspent_credits_count %></span> credits -
has <span id="org-credits-number"><%= @organizations.first.credits.unspent.size %></span> credits -
<a id="org-credits-purchase-link" target="_blank" rel="noopener noreferrer" href="/credits/purchase?organization_id=<%= @organizations.first.id %>" data-no-instant>Buy More</a>
</div>
<%= javascript_pack_tag "orgCreditsSelector", defer: true %>

View file

@ -1,10 +1,11 @@
<% title "Listings Dashboard" %>
<div id="classifieds-listings-dashboard" data-listings="<%= @classified_listings.to_json(
only: %i[title tag_list created_at expires_at bumped_at updated_at category id user_id slug organization_id location published],
include: {
author: { only: %i[username name], methods: %i[username profile_image_90] },
},
)%>" data-usercredits="<%= @user_credits %>"
only: %i[title tag_list created_at expires_at bumped_at updated_at category id user_id slug organization_id location published],
include: {
author: { only: %i[username name], methods: %i[username profile_image_90] },
},
)%>" data-usercredits="<%= @user_credits %>"
data-orglistings="<%= @org_listings.to_json(
only: %i[title tag_list created_at expires_at bumped_at updated_at category id user_id slug organization_id location published],
include: {

View file

@ -0,0 +1,18 @@
<%= javascript_include_tag "application" %>
<div class="container delete-confirm">
<h4><%= @classified_listing.title %></h4>
<h1>Are you sure you want to delete this listing?</h1>
<h2>
You cannot undo this action, perhaps you just want to
<a data-no-instant href="/listings/<%= @classified_listing.id %>/edit">
<%= @classified_listing.published ? "unpublish" : "edit" %>
</a>
instead?
</h2>
<h2>
<a class="delete-link" data-no-instant rel="nofollow" data-method="delete" href="/listings/<%= @classified_listing.id %>">DELETE</a>
</h2>
</div>

View file

@ -1,31 +1,13 @@
<div class="classifieds-container">
<% title "Edit Listing" %>
<div class="classifieds-container classifieds-form-container">
<%= form_for(@classified_listing) do |f| %>
<header>
<a href="/listings/dashboard" class="listings-back-button">&lt; return to listings dashboard</a>
<h2>You can only edit title/body/tags of drafts or within the first 24 hours of listing or bumping</h2>
</header>
<div class="classified-form-inner">
<p>
You can bump your listing for the same price as the original listing
</p>
<h2>
Last Published or Bumped: <%= time_ago_in_words @classified_listing.bumped_at %> ago
</h2>
<% if (@classified_listing.bumped_at + 30.days) < Date.today %>
<h4>
(Expired <%= time_ago_in_words (@classified_listing.bumped_at + 30.days) %> ago)
</h4>
<% else %>
<h4>
(Expires in <%= time_ago_in_words (@classified_listing.bumped_at + 30.days) %>)
</h4>
<% end %>
<input type="hidden" name="classified_listing[action]" value="bump" />
<%= f.submit "Bump Listing", class: "cta cta-main-listing-form" %>
</div>
<% end %>
<%= form_for(@classified_listing) do |f| %>
<div class="classified-form-inner">
<h2>
<em>You can only edit title/body/tags within the first 24 hours of listing or bumping</em>
</h2>
<% if @classified_listing.bumped_at > 24.hours.ago %>
<% if (@classified_listing.bumped_at && @classified_listing.bumped_at > 24.hours.ago) || @classified_listing.updated_at && !@classified_listing.published %>
<div class="field">
<%= f.label "title", "Title" %>
<%= f.text_field "title", maxlength: 128, placeholder: "128 characters max, plain text" %>
@ -44,27 +26,54 @@
</div>
<div class="field">
<%= f.label "expires_at", "Custom Expire Date" %>
<%= f.date_field "expires_at", min: Date.tomorrow, max: @classified_listing.bumped_at + 30.days %>
<%= f.date_field "expires_at", min: Date.tomorrow, max: @classified_listing.natural_expiration_date %>
</div>
<div class="field">
<%= f.label "contact_via_connect", "Allow Users to Message Me Via In-App Chat (DEV Connect)" %>
<%= f.check_box "contact_via_connect" %>
</div>
<%= f.submit "Update Listings Info", class: "cta cta-main-listing-form" %>
<% if @classified_listing.published %>
<%= f.submit "Update Listings Info", class: "cta cta-main-listing-form" %>
<% else %>
<%= f.submit "Update Draft", class: "cta cta-main-listing-form cta-draft" %>
<% end %>
<% end %>
</div>
<% end %>
<% if @classified_listing.bumped_at %>
<%= form_for(@classified_listing) do |f| %>
<header>
<a href="<%= @classified_listing.path %>" class="listings-back-button" style="font-size: 1.1em;">Last Published/Bumped: <%= time_ago_in_words @classified_listing.bumped_at %> ago</a>
<h2>You can bump your listing for the same price as the original listing</h2>
</header>
<div class="classified-form-inner">
<% if (@classified_listing.natural_expiration_date) < Date.today %>
<h4>
(Expired <%= time_ago_in_words @classified_listing.natural_expiration_date %> ago)
</h4>
<% else %>
<h4>
(Expires in <%= time_ago_in_words @classified_listing.natural_expiration_date %>)
</h4>
<% end %>
<input type="hidden" name="classified_listing[action]" value="bump" />
<%= f.submit "Bump Listing", class: "cta cta-main-listing-form" %>
</div>
<% end %>
<% end %>
<%= form_for(@classified_listing) do |f| %>
<div class="classified-form-inner">
<h2>
Published: <%= @classified_listing.published %>
</h2>
<% unless @classified_listing.published %>
<h2 style="text-align: center; margin-top: 15px;">
This listing is not published
</h2>
<% end %>
<% if @classified_listing.published == false %>
<%# <input type="hidden" name="classified_listing[action]" value="publish" />
<%= f.submit "Publish Listing", class: "classified-listings-publish cta-main-listing-form" %>
<%# <% else %>
<input type="hidden" name="classified_listing[action]" value="publish" />
<%= f.submit "Publish Listing", class: "cta classified-listings-publish cta-main-listing-form" %>
<% else %>
<input type="hidden" name="classified_listing[action]" value="unpublish" />
<%= f.submit "Unpublish Listing", class: "classified-listings-unpublish cta-main-listing-form" %>
<%= f.submit "Unpublish Listing", class: "cta classified-listings-unpublish cta-main-listing-form" %>
<% end %>
</div>
<% end %>

View file

@ -1,6 +1,5 @@
<% title "New Listing" %>
<div class="classifieds-container" id="classifieds-container">
<%= render 'form', classified_listing: @classified_listing %>
<div class="classifieds-container classifieds-form-container" id="classifieds-container">
<%= render "form", classified_listing: @classified_listing %>
</div>

View file

@ -34,6 +34,9 @@
<p>
<a href="<%= new_classified_listing_path %>">Create a Listing</a>
</p>
<p>
<a href="/listings/dashboard">Go to Listings Dashboard</a>
</p>
<% if @organizations.present? %>
<p>

View file

@ -23,6 +23,9 @@
Upload a Video 🎥
</a>
<% end %>
<a class="video-upload-cta cta" href="/listings/dashboard" data-no-instant>
Create/Manage Listings
</a>
<%= select_tag "dashhboard_sort", options_for_select(sort_options, params[:sort]) %>
<% if @articles.any? {|article| article.archived } %>
<%= link_to "Show archived", "javascript:;", id: "toggleArchivedLink" %>

View file

@ -28,7 +28,7 @@
<div class="grid-item"><%= listing.category %></div>
<div class="grid-item"><%= listing.cached_tag_list %></div>
<div class="grid-item"><%= listing.published ? "Yes" : "No" %></div>
<div class="grid-item"><%= time_ago_in_words(listing.bumped_at) %> ago</div>
<div class="grid-item"><%= listing.bumped_at ? time_ago_in_words(listing.bumped_at) + " ago" : "Draft" %></div>
</div>
<div class="buffer-area" style="text-align: left">
<br>

View file

@ -159,7 +159,7 @@ Rails.application.routes.draw do
resources :tag_adjustments, only: %i[create destroy]
resources :rating_votes, only: [:create]
resources :page_views, only: %i[create update]
resources :classified_listings, path: :listings, only: %i[index new create edit update delete dashboard]
resources :classified_listings, path: :listings, only: %i[index new create edit update destroy dashboard]
resources :credits, only: %i[index new create] do
get "purchase", on: :collection, to: "credits#new"
end
@ -177,6 +177,8 @@ Rails.application.routes.draw do
get "/listings/:category/:slug" => "classified_listings#index", as: :classified_listing_slug
get "/listings/:category/:slug/:view" => "classified_listings#index",
constraints: { view: /moderate/ }
get "/listings/:category/:slug/delete_confirm" => "classified_listings#delete_confirm"
delete "/listings/:category/:slug" => "classified_listings#destroy"
get "/notifications/:filter" => "notifications#index"
get "/notifications/:filter/:org_id" => "notifications#index"
get "/notification_subscriptions/:notifiable_type/:notifiable_id" => "notification_subscriptions#show"

View file

@ -13,6 +13,18 @@ RSpec.describe "ClassifiedListings", type: :request do
}
}
end
let(:draft_params) do
{
classified_listing: {
title: "this a draft",
body_markdown: "something draft",
category: "cfp",
tag_list: "",
contact_via_connect: true,
action: "draft"
}
}
end
describe "GET /listings" do
let(:listing) { create(:classified_listing, user: user) }
@ -56,6 +68,11 @@ RSpec.describe "ClassifiedListings", type: :request do
describe "GET /listings/new" do
before { sign_in user }
it "shows the option to create draft" do
get "/listings/new"
expect(response.body).to include "You will not be charged credits to save a draft."
end
context "when the user has no credits" do
it "shows the proper messages" do
get "/listings/new"
@ -154,6 +171,11 @@ RSpec.describe "ClassifiedListings", type: :request do
expect(response.body).to redirect_to("/credits")
end
it "redirects to /listings/dashboard for listing draft" do
post "/listings", params: draft_params
expect(response).to redirect_to "/listings/dashboard"
end
it "redirects to /listings" do
post "/listings", params: listing_params
expect(response).to redirect_to "/listings"
@ -165,6 +187,16 @@ RSpec.describe "ClassifiedListings", type: :request do
expect(user.credits.spent.size).to eq(listing_cost)
end
it "creates a listing draft under the org" do
org_admin = create(:user, :org_admin)
org_id = org_admin.organizations.first.id
Credit.create(organization_id: org_id)
draft_params[:classified_listing][:organization_id] = org_id
sign_in org_admin
post "/listings", params: draft_params
expect(ClassifiedListing.first.organization_id).to eq org_id
end
it "creates a listing under the org" do
org_admin = create(:user, :org_admin)
org_id = org_admin.organizations.first.id
@ -175,6 +207,12 @@ RSpec.describe "ClassifiedListings", type: :request do
expect(ClassifiedListing.first.organization_id).to eq org_id
end
it "does not create a listing draft for an org not belonging to the user" do
org = create(:organization)
draft_params[:classified_listing][:organization_id] = org.id
expect { post "/listings", params: draft_params }.to raise_error(Pundit::NotAuthorizedError)
end
it "does not create a listing for an org not belonging to the user" do
org = create(:organization)
listing_params[:classified_listing][:organization_id] = org.id
@ -188,6 +226,14 @@ RSpec.describe "ClassifiedListings", type: :request do
expect(spent_credit.spent_at).not_to be_nil
end
it "creates listing draft and does not subtract credits" do
allow(Credits::Buyer).to receive(:call).and_raise(ActiveRecord::Rollback)
expect do
post "/listings", params: draft_params
end.to change(ClassifiedListing, :count).by(1).
and change(user.credits.spent, :size).by(0)
end
it "does not create a listing or subtract credits if the purchase does not go through" do
allow(Credits::Buyer).to receive(:call).and_raise(ActiveRecord::Rollback)
expect do
@ -209,11 +255,15 @@ RSpec.describe "ClassifiedListings", type: :request do
describe "PUT /listings/:id" do
let(:listing) { create(:classified_listing, user: user) }
let(:listing_draft) { create(:classified_listing, user: user) }
let(:organization) { create(:organization) }
let(:org_listing) { create(:classified_listing, user: user, organization: organization) }
let(:org_listing_draft) { create(:classified_listing, user: user, organization: organization) }
before do
sign_in user
listing_draft.update_columns(bumped_at: nil, published: false)
org_listing_draft.update_columns(bumped_at: nil, published: false)
end
context "when the bump action is called" do
@ -272,5 +322,156 @@ RSpec.describe "ClassifiedListings", type: :request do
expect(org_listing.reload.bumped_at >= previous_bumped_at).to eq(true)
end
end
context "when the publish action is called" do
let(:params) { { classified_listing: { action: "publish" } } }
it "publishes a draft and charges user credits if first publish" do
cost = ClassifiedListing.cost_by_category(listing_draft.category)
create_list(:credit, cost, user: user)
expect do
put "/listings/#{listing_draft.id}", params: params
end.to change(user.credits.spent, :size).by(cost)
end
it "publishes a draft and ensures published column is true" do
cost = ClassifiedListing.cost_by_category(listing_draft.category)
create_list(:credit, cost, user: user)
put "/listings/#{listing_draft.id}", params: params
expect(listing_draft.reload.published).to eq(true)
end
it "publishes an org draft and charges org credits if first publish" do
cost = ClassifiedListing.cost_by_category(org_listing_draft.category)
create_list(:credit, cost, organization: organization)
expect do
put "/listings/#{org_listing_draft.id}", params: params
end.to change(organization.credits.spent, :size).by(cost)
end
it "publishes an org draft and ensures published column is true" do
cost = ClassifiedListing.cost_by_category(org_listing_draft.category)
create_list(:credit, cost, organization: organization)
put "/listings/#{org_listing_draft.id}", params: params
expect(org_listing_draft.reload.published).to eq(true)
end
it "publishes a draft that was charged and is within 30 days of bump doesn't charge credits" do
listing.update_column(:published, false)
expect do
put "/listings/#{listing.id}", params: params
end.to change(user.credits.spent, :size).by(0)
end
it "publishes a draft that was charged and is within 30 days of bump and successfully sets published as true" do
listing.update_column(:published, false)
put "/listings/#{listing.id}", params: params
expect(listing.reload.published).to eq(true)
end
it "fails to publish draft and doesn't charge credits" do
expect do
put "/listings/#{listing_draft.id}", params: params
end.to change(user.credits.spent, :size).by(0)
end
it "fails to publish draft and published remains false" do
put "/listings/#{listing_draft.id}", params: params
expect(listing_draft.reload.published).to eq(false)
end
end
context "when the unpublish action is called" do
let(:params) { { classified_listing: { action: "unpublish" } } }
it "unpublishes a published listing" do
put "/listings/#{listing.id}", params: params
expect(listing.reload.published).to eq(false)
end
end
end
describe "DEL /listings/:id" do
let!(:listing) { create(:classified_listing, user: user) }
let!(:listing_draft) { create(:classified_listing, user: user, bumped_at: nil, published: false) }
let(:organization) { create(:organization) }
let!(:org_listing) { create(:classified_listing, user: user, organization: organization) }
let!(:org_listing_draft) { create(:classified_listing, user: user, organization: organization, bumped_at: nil, published: false) }
before do
sign_in user
end
context "when deleting draft" do
it "redirect to dashboard" do
delete "/listings/#{listing_draft.id}"
expect(response).to redirect_to("/listings/dashboard")
end
it "have status 302 found" do
delete "/listings/#{listing_draft.id}"
expect(response).to have_http_status(:found)
end
it "decrease total listings count by 1" do
expect do
delete "/listings/#{listing_draft.id}"
end.to change(ClassifiedListing, :count).by(-1)
end
end
context "when deleting listing" do
it "redirect to dashboard" do
delete "/listings/#{listing.id}"
expect(response).to redirect_to("/listings/dashboard")
end
it "have status 302 found" do
delete "/listings/#{listing.id}"
expect(response).to have_http_status(:found)
end
it "decrease total listings count by 1" do
expect do
delete "/listings/#{listing.id}"
end.to change(ClassifiedListing, :count).by(-1)
end
end
context "when deleting org listing" do
it "redirect to dashboard" do
delete "/listings/#{org_listing_draft.id}"
expect(response).to redirect_to("/listings/dashboard")
end
it "have status 302 found" do
delete "/listings/#{org_listing_draft.id}"
expect(response).to have_http_status(:found)
end
it "decrease total listings count by 1" do
expect do
delete "/listings/#{org_listing_draft.id}"
end.to change(ClassifiedListing, :count).by(-1)
end
end
context "when deleting org listing" do
it "redirect to dashboard" do
delete "/listings/#{org_listing.id}"
expect(response).to redirect_to("/listings/dashboard")
end
it "have status 302 found" do
delete "/listings/#{org_listing.id}"
expect(response).to have_http_status(:found)
end
it "decrease total listings count by 1" do
expect do
delete "/listings/#{org_listing.id}"
end.to change(ClassifiedListing, :count).by(-1)
end
end
end
end