Add pinned articles box to profiles (#3269)

* Initial pins work

* Add pin box to profiles

* Fix test snapshot spacing and optimize svg

* Fix listings spacing
This commit is contained in:
Ben Halpern 2019-06-24 15:18:29 -04:00 committed by GitHub
parent 0267d4e829
commit dcbb188cf1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 827 additions and 203 deletions

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 508.488 508.488"><path d="M386.284 282.387c-12.999-14.779-35.755-22.947-52.568-27.174V90.58c16.622-5.626 30.734-12.681 41.889-20.913 9.535-7.024 14.334-18.815 12.522-30.511-1.843-11.696-9.98-21.421-21.294-25.204C351.387 8.74 316.013 0 254.26 0c-65.313 0-101.386 10.075-113.877 14.397a31.77 31.77 0 0 0-20.976 25.108c-1.812 11.569 2.924 23.233 12.3 30.32 11.315 8.486 25.776 15.669 43.065 21.326h.032v164.061c-16.781 4.227-39.569 12.395-52.6 27.174a31.848 31.848 0 0 0-7.119 28.159c2.288 9.948 9.28 18.211 18.72 22.184 22.566 9.471 60.927 15.383 104.596 16.59l-.064.254v95.347c0 17.544 7.119 63.565 15.891 63.565 8.74 0 15.891-46.021 15.891-63.565v-95.347l-.032-.254c43.669-1.176 82.031-7.215 104.596-16.622 9.439-3.909 16.463-12.204 18.72-22.152 2.289-10.01-.349-20.498-7.119-28.158zm-132.056 35.406c-57.717 0-93.472-8.263-108.188-14.397 5.721-6.484 19.006-12.967 36.55-17.417l23.964-6.039V68.078l-21.898-7.151c-16.59-5.435-27.365-11.632-33.88-16.559 16.908-5.848 51.138-12.618 103.452-12.618 51.265 0 85.336 6.515 102.53 12.3-6.706 4.958-17.449 11.029-33.213 16.4l-21.644 7.31v212.18l23.964 6.039c17.608 4.45 30.893 10.933 36.55 17.417-14.683 6.134-50.438 14.397-108.187 14.397z" fill="#090509"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -216,6 +216,44 @@
}
}
}
.pinned-articles {
@include themeable(background, theme-container-background, $white);
@include themeable(
border,
theme-container-border,
1px solid $outline-color
);
padding: 8px 0px;
margin: auto;
margin-top: 8px;
@media screen and (min-width: 750px) {
border-radius: 3px;
box-sizing: border-box;
padding: 8px 8px;
padding-right: 9px;
width: calc(100% + 2px);
max-width: 722px;
}
.pinned-articles-indicator {
text-align: left;
font-size: 15px;
font-weight: bold;
padding-top: 8px;
padding-left: 2.8%;
@media screen and (min-width: 750px) {
padding-left: 22px;
}
@media screen and (min-width: 950px) {
padding-left: 2px;
}
img {
width: 20px;
transform: rotate(-8deg);
vertical-align: -5px;
@include themeable(filter, theme-social-icon-invert, invert(0));
}
}
}
a {
@include themeable(color, theme-container-color, $black);
}

View file

@ -513,13 +513,13 @@
.dashboard-actions {
padding: 7px 0px 10px;
.mute-form {
form {
margin: 0;
padding: 0;
display: inline;
input[type='submit'] {
input[type='submit'], button {
height: 30.4px;
border: 2px solid transparent;
}
@ -747,6 +747,19 @@
padding: 50px 0px 120px;
margin-bottom: -160px;
line-height: 1.3em;
.manage-page-error {
background:$red;
color:white;
padding:20px 10px;
line-height: 1.4em;
border-radius: 3px;
}
.manage-page-success {
background:$green;
padding:20px 10px;
line-height: 1.4em;
border-radius: 3px;
}
li {
margin: 20px auto;
}

View file

@ -0,0 +1,30 @@
class ProfilePinsController < ApplicationController
before_action :authenticate_user!, only: %i[create]
def create
@profile_pin = ProfilePin.new
@profile_pin.profile_id = current_user.id
@profile_pin.profile_type = "User"
@profile_pin.pinnable_id = profile_pin_params[:pinnable_id].to_i
@profile_pin.pinnable_type = "Article"
if @profile_pin.save!
flash[:pins_success] = "📌 Pinned! (pinned posts display chronologically, 5 max)"
else
flash[:pins_error] = "You can only have five pins"
end
redirect_back(fallback_location: "/dashboard")
end
def update
# for removing pinnable
ProfilePin.find(params[:id]).destroy
flash[:pins_success] = "🗑 Pin removed"
redirect_back(fallback_location: "/dashboard")
end
private
def profile_pin_params
params.require(:profile_pin).permit(:pinnable_id)
end
end

View file

@ -164,8 +164,12 @@ class StoriesController < ApplicationController
return
end
assign_user_comments
@pinned_stories = Article.published.where(id: @user.profile_pins.pluck(:pinnable_id)).
limited_column_select.
order("published_at DESC").decorate
@stories = ArticleDecorator.decorate_collection(@user.articles.published.
limited_column_select.
where.not(id: @pinned_stories.pluck(:id)).
order("published_at DESC").page(@page).per(user_signed_in? ? 2 : 5))
@article_index = true
@list_of = "articles"

View file

@ -184,9 +184,8 @@ min read・
</button>
</div>
);
}
return '';
}
return '';
}
render() {

View file

@ -5,12 +5,106 @@ import { ListingDashboard } from '../listingDashboard';
const doc = new JSDOM('<!doctype html><html><body></body></html>');
global.document = doc;
const l = [{id:23,bumped_at:"2019-06-11T16:45:37.229Z",category:"cfp",organization_id:null,slug:"asdfasdf-2ea8",title:"asdfasdf",updated_at:"2019-06-11T16:45:37.237Z",user_id:11,tag_list:["computerscience","career"],author:{name:"MarioSee",username:"mariocsee",profile_image_90:"/uploads/user/profile_image/11/e594d777-b57b-41d6-a793-5d8127bd11b3.jpeg"}},{id:24,bumped_at:"2019-06-11T16:59:16.312Z",category:"events",organization_id:2,slug:"yoyoyoyoyoooooooo-4jcb",title:"YOYOYOYOYOOOOOOOO",updated_at:"2019-06-11T16:59:16.316Z",user_id:11,tag_list:["computerscience","conference","career"],author:{name:"Yoyodyne",username:"org3737",profile_image_90:"/uploads/organization/profile_image/2/5edb1e49-bea9-4e99-bc32-acc10c52a935.png"}},{id:25,bumped_at:"2019-06-11T17:01:25.143Z",category:"cfp",organization_id:3,slug:"hehhehe-5hld",title:"hehhehe",updated_at:"2019-06-11T17:01:25.169Z",user_id:11,tag_list:[],author:{name:"Infotrode",username:"org5254",profile_image_90:"/uploads/organization/profile_image/3/04d4e1f1-c2e0-4147-81e2-bc8a2657296b.png"}}];
global.document.body.innerHTML = `<div id="classifieds-listings-dashboard" data-listings=${JSON.stringify(l)} data-usercredits="3" data-orglistings=${JSON.stringify([{id:24,bumped_at:"2019-06-11T16:59:16.312Z",category:"events",organization_id:2,slug:"yoyoyoyoyoooooooo-4jcb",title:"YOYOYOYOYOOOOOOOO",updated_at:"2019-06-11T16:59:16.316Z",user_id:11,tag_list:["computerscience","conference","career"],author:{name:"Yoyodyne",username:"org3737",profile_image_90:"/uploads/organization/profile_image/2/5edb1e49-bea9-4e99-bc32-acc10c52a935.png"}},{id:25,bumped_at:"2019-06-11T17:01:25.143Z",category:"cfp",organization_id:3,slug:"hehhehe-5hld",title:"hehhehe",updated_at:"2019-06-11T17:01:25.169Z",user_id:11,tag_list:[],author:{name:"Infotrode",username:"org5254",profile_image_90:"/uploads/organization/profile_image/3/04d4e1f1-c2e0-4147-81e2-bc8a2657296b.png"}}])} data-orgs=${JSON.stringify([{id:2,name:"Yoyodyne",slug:"org3737",unspent_credits_count:1},{id:3,name:"Infotrode",slug:"org5254",unspent_credits_count:1}])} ></div>`;
const l = [
{
id: 23,
bumped_at: '2019-06-11T16:45:37.229Z',
category: 'cfp',
organization_id: null,
slug: 'asdfasdf-2ea8',
title: 'asdfasdf',
updated_at: '2019-06-11T16:45:37.237Z',
user_id: 11,
tag_list: ['computerscience', 'career'],
author: {
name: 'MarioSee',
username: 'mariocsee',
profile_image_90:
'/uploads/user/profile_image/11/e594d777-b57b-41d6-a793-5d8127bd11b3.jpeg',
},
},
{
id: 24,
bumped_at: '2019-06-11T16:59:16.312Z',
category: 'events',
organization_id: 2,
slug: 'yoyoyoyoyoooooooo-4jcb',
title: 'YOYOYOYOYOOOOOOOO',
updated_at: '2019-06-11T16:59:16.316Z',
user_id: 11,
tag_list: ['computerscience', 'conference', 'career'],
author: {
name: 'Yoyodyne',
username: 'org3737',
profile_image_90:
'/uploads/organization/profile_image/2/5edb1e49-bea9-4e99-bc32-acc10c52a935.png',
},
},
{
id: 25,
bumped_at: '2019-06-11T17:01:25.143Z',
category: 'cfp',
organization_id: 3,
slug: 'hehhehe-5hld',
title: 'hehhehe',
updated_at: '2019-06-11T17:01:25.169Z',
user_id: 11,
tag_list: [],
author: {
name: 'Infotrode',
username: 'org5254',
profile_image_90:
'/uploads/organization/profile_image/3/04d4e1f1-c2e0-4147-81e2-bc8a2657296b.png',
},
},
];
global.document.body.innerHTML = `<div id="classifieds-listings-dashboard" data-listings=${JSON.stringify(
l,
)} data-usercredits="3" data-orglistings=${JSON.stringify([
{
id: 24,
bumped_at: '2019-06-11T16:59:16.312Z',
category: 'events',
organization_id: 2,
slug: 'yoyoyoyoyoooooooo-4jcb',
title: 'YOYOYOYOYOOOOOOOO',
updated_at: '2019-06-11T16:59:16.316Z',
user_id: 11,
tag_list: ['computerscience', 'conference', 'career'],
author: {
name: 'Yoyodyne',
username: 'org3737',
profile_image_90:
'/uploads/organization/profile_image/2/5edb1e49-bea9-4e99-bc32-acc10c52a935.png',
},
},
{
id: 25,
bumped_at: '2019-06-11T17:01:25.143Z',
category: 'cfp',
organization_id: 3,
slug: 'hehhehe-5hld',
title: 'hehhehe',
updated_at: '2019-06-11T17:01:25.169Z',
user_id: 11,
tag_list: [],
author: {
name: 'Infotrode',
username: 'org5254',
profile_image_90:
'/uploads/organization/profile_image/3/04d4e1f1-c2e0-4147-81e2-bc8a2657296b.png',
},
},
])} data-orgs=${JSON.stringify([
{ id: 2, name: 'Yoyodyne', slug: 'org3737', unspent_credits_count: 1 },
{ id: 3, name: 'Infotrode', slug: 'org5254', unspent_credits_count: 1 },
])} ></div>`;
global.window = doc.defaultView;
const listingState = { listings:
[ { id: 23,
const listingState = {
listings: [
{
id: 23,
bumped_at: '2019-06-11T16:45:37.229Z',
category: 'cfp',
organization_id: null,
@ -19,8 +113,10 @@ const listingState = { listings:
updated_at: '2019-06-11T16:45:37.237Z',
user_id: 11,
tag_list: [Array],
author: [Object] },
{ id: 24,
author: [Object],
},
{
id: 24,
bumped_at: '2019-06-11T16:59:16.312Z',
category: 'events',
organization_id: 2,
@ -29,8 +125,10 @@ const listingState = { listings:
updated_at: '2019-06-11T16:59:16.316Z',
user_id: 11,
tag_list: [Array],
author: [Object] },
{ id: 25,
author: [Object],
},
{
id: 25,
bumped_at: '2019-06-11T17:01:25.143Z',
category: 'cfp',
organization_id: 3,
@ -39,9 +137,12 @@ const listingState = { listings:
updated_at: '2019-06-11T17:01:25.169Z',
user_id: 11,
tag_list: [],
author: [Object] } ],
orgListings:
[ { id: 24,
author: [Object],
},
],
orgListings: [
{
id: 24,
bumped_at: '2019-06-11T16:59:16.312Z',
category: 'events',
organization_id: 2,
@ -50,8 +151,10 @@ const listingState = { listings:
updated_at: '2019-06-11T16:59:16.316Z',
user_id: 11,
tag_list: [Array],
author: [Object] },
{ id: 25,
author: [Object],
},
{
id: 25,
bumped_at: '2019-06-11T17:01:25.143Z',
category: 'cfp',
organization_id: 3,
@ -60,24 +163,22 @@ const listingState = { listings:
updated_at: '2019-06-11T17:01:25.169Z',
user_id: 11,
tag_list: [],
author: [Object] } ],
orgs:
[ { id: 2,
name: 'Yoyodyne',
slug: 'org3737',
unspent_credits_count: 1 },
{ id: 3,
name: 'Infotrode',
slug: 'org5254',
unspent_credits_count: 1 } ],
userCredits: '3',
selectedListings: 'user' };
author: [Object],
},
],
orgs: [
{ id: 2, name: 'Yoyodyne', slug: 'org3737', unspent_credits_count: 1 },
{ id: 3, name: 'Infotrode', slug: 'org5254', unspent_credits_count: 1 },
],
userCredits: '3',
selectedListings: 'user',
};
describe('<ListingDashboard />', () => {
it('should load listing dashboard', () => {
const tree = deep(<ListingDashboard />);
expect(tree).toMatchSnapshot();
})
});
describe('should load the proper elements', () => {
const context = deep(<ListingDashboard />);
@ -85,29 +186,85 @@ describe('<ListingDashboard />', () => {
context.setState(listingState);
it('for user and org buttons', () => {
expect(context.find('.rounded-btn').at(0).text()).toEqual('Personal')
expect(context.find('.rounded-btn').at(1).text()).toEqual(listingState.orgs[0].name)
expect(context.find('.rounded-btn').at(2).text()).toEqual(listingState.orgs[1].name)
expect(
context
.find('.rounded-btn')
.at(0)
.text(),
).toEqual('Personal');
expect(
context
.find('.rounded-btn')
.at(1)
.text(),
).toEqual(listingState.orgs[0].name);
expect(
context
.find('.rounded-btn')
.at(2)
.text(),
).toEqual(listingState.orgs[1].name);
context
.find('.rounded-btn')
.at(1)
.simulate('click');
expect(
context
.find('.rounded-btn')
.at(1)
.attr('className'),
).toEqual('rounded-btn active');
expect(context.state('selectedListings')).toEqual(
listingState.orgs[0].id,
);
});
context.find('.rounded-btn').at(1).simulate('click');
expect(context.find('.rounded-btn').at(1).attr('className')).toEqual('rounded-btn active')
expect(context.state('selectedListings')).toEqual(listingState.orgs[0].id)
})
it('for listing and credits header', () => {
expect(context.find('.dashboard-listings-header-wrapper').exists()).toEqual(true);
expect(
context.find('.dashboard-listings-header-wrapper').exists(),
).toEqual(true);
expect(context.find('.dashboard-listings-header').exists()).toEqual(true);
expect(context.find('.dashboard-listings-header').at(0).childAt(0).text()).toEqual('Listings');
expect(context.find('.dashboard-listings-header').at(0).childAt(2).text()).toEqual('Create a Listing');
expect(context.find('.dashboard-listings-header').at(1).childAt(0).text()).toEqual('Credits');
expect(context.find('.dashboard-listings-header').at(1).childAt(2).text()).toEqual('Buy Credits');
})
expect(
context
.find('.dashboard-listings-header')
.at(0)
.childAt(0)
.text(),
).toEqual('Listings');
expect(
context
.find('.dashboard-listings-header')
.at(0)
.childAt(2)
.text(),
).toEqual('Create a Listing');
expect(
context
.find('.dashboard-listings-header')
.at(1)
.childAt(0)
.text(),
).toEqual('Credits');
expect(
context
.find('.dashboard-listings-header')
.at(1)
.childAt(2)
.text(),
).toEqual('Buy Credits');
});
it('for listingRow view', () => {
expect(context.find('.dashboard-listings-view').exists()).toEqual(true);
expect(context.find('.dashboard-listings-view').children().text()).toEqual(context.find('ListingRow').text());
})
})
})
expect(
context
.find('.dashboard-listings-view')
.children()
.text(),
).toEqual(context.find('ListingRow').text());
});
});
});

View file

@ -1,42 +1,58 @@
import PropTypes from 'prop-types';
import { h } from 'preact';
export const ListingRow = ({listing}) => {
export const ListingRow = ({ listing }) => {
const tagLinks = listing.tag_list.map(tag => (
<a href={`/listings?t=${tag}`} data-no-instant>
#
#
{tag}
{' '}
</a>
));
const listingDate = listing.bumped_at ? (new Date(listing.bumped_at.toString())).toLocaleDateString("default", {day: "2-digit", month: "short"}) : (new Date(listing.updated_at.toString())).toLocaleDateString("default", {day: "2-digit", month: "short"}) ;
const orgName = (l) => (l.organization_id) ? (<span className="listing-org">{l.author.name}</span>) : '';
const listingDate = listing.bumped_at
? new Date(listing.bumped_at.toString()).toLocaleDateString('default', {
day: '2-digit',
month: 'short',
})
: new Date(listing.updated_at.toString()).toLocaleDateString('default', {
day: '2-digit',
month: 'short',
});
const orgName = l =>
l.organization_id ? (
<span className="listing-org">{l.author.name}</span>
) : (
''
);
return(
return (
<div className="dashboard-listing-row">
{orgName(listing)}
<a href={`${`${listing.category }/${ listing.slug}`}`}>
<h2>
{listing.title}
</h2>
<a href={`${`${listing.category}/${listing.slug}`}`}>
<h2>{listing.title}</h2>
</a>
<span className="dashboard-listing-date">
{listingDate}
{' '}
</span>
<span className="dashboard-listing-category"><a href={`/listings/${listing.category}/`}>{listing.category}</a></span>
<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
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>
</div>
);
}
};
ListingRow.propTypes = {
listing: PropTypes.object.isRequired,

View file

@ -7,12 +7,12 @@ export class ListingDashboard extends Component {
orgListings: [],
orgs: [],
userCredits: 0,
selectedListings: "user",
}
selectedListings: 'user',
};
componentDidMount() {
const t = this;
const container = document.getElementById('classifieds-listings-dashboard')
const container = document.getElementById('classifieds-listings-dashboard');
let listings = [];
let orgs = [];
let orgListings = [];
@ -24,46 +24,100 @@ export class ListingDashboard extends Component {
}
render() {
const { listings, orgListings, userCredits, orgs, selectedListings } = this.state
const {
listings,
orgListings,
userCredits,
orgs,
selectedListings,
} = this.state;
const showListings = (selected, userListings, organizationListings) => {
return (selected === "user") ? userListings.map(listing => <ListingRow listing={listing} />) : organizationListings.map(listing => (listing.organization_id === selected) ? <ListingRow listing={listing} /> : '');
}
return selected === 'user'
? userListings.map(listing => <ListingRow listing={listing} />)
: organizationListings.map(listing =>
listing.organization_id === selected ? (
<ListingRow listing={listing} />
) : (
''
),
);
};
const orgButtons = orgs.map(org => (
<span onClick={() => this.setState({selectedListings: org.id})} className={"rounded-btn " + (selectedListings === org.id ? 'active': '')}>
<span
onClick={() => this.setState({ selectedListings: org.id })}
className={`rounded-btn ${selectedListings === org.id ? 'active' : ''}`}
>
{org.name}
</span>
))
));
const listingLength = (selected, userListings, organizationListings) => {
return (selected === "user") ? (<h4>Listings Made: {userListings.length}</h4>) : (<h4>Listings Made: {organizationListings.filter((listing) => (listing.organization_id == selected)).length}</h4>);
}
return selected === 'user' ? (
<h4>
Listings Made:
{' '}
{userListings.length}
</h4>
) : (
<h4>
Listings Made:
{' '}
{
organizationListings.filter(
listing => listing.organization_id === selected,
).length
}
</h4>
);
};
const creditCount = (selected, userCreds, organizations) => {
return (selected === "user") ? (<h4>Credits Available: {userCredits}</h4>) : (<h4>Credits Available: {organizations.find((org) => (org.id === selected)).unspent_credits_count}</h4>);
}
return selected === 'user' ? (
<h4>
Credits Available:
{' '}
{userCredits}
</h4>
) : (
<h4>
Credits Available:
{' '}
{organizations.find(org => org.id === selected).unspent_credits_count}
</h4>
);
};
return (
<div className="dashboard-listings-container">
<span onClick={() => this.setState({selectedListings: "user"})} className={"rounded-btn " + (selectedListings === "user" ? 'active': '')}>Personal</span>
<span
onClick={() => this.setState({ selectedListings: 'user' })}
className={`rounded-btn ${
selectedListings === 'user' ? 'active' : ''
}`}
>
Personal
</span>
{orgButtons}
<div className="dashboard-listings-header-wrapper">
<div className="dashboard-listings-header">
<h3>Listings</h3>
{listingLength(selectedListings, listings, orgListings)}
<a href='/listings/new'>Create a Listing</a>
<a href="/listings/new">Create a Listing</a>
</div>
<div className="dashboard-listings-header">
<h3>Credits</h3>
{creditCount(selectedListings, userCredits, orgs)}
<a href="/credits/purchase" data-no-instant>Buy Credits</a>
<a href="/credits/purchase" data-no-instant>
Buy Credits
</a>
</div>
</div>
<div className="dashboard-listings-view">
{showListings(selectedListings, listings, orgListings)}
</div>
</div>
)
);
}
}

View file

@ -28,17 +28,17 @@ export class Listings extends Component {
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 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(',')
tags = params.t.split(',');
}
const query = params.q || ''
const query = params.q || '';
let listings = [];
if (tags.length === 0 && query === '') {
listings = JSON.parse(container.dataset.listings)
listings = JSON.parse(container.dataset.listings);
}
let openedListing = null;
let slug = null;
@ -47,27 +47,35 @@ export class Listings extends Component {
slug = openedListing.slug;
document.body.classList.add('modal-open');
}
t.setState({query, tags, index, category, allCategories, listings, openedListing, slug });
t.setState({
query,
tags,
index,
category,
allCategories,
listings,
openedListing,
slug,
});
t.listingSearch(query, tags, category, slug);
t.setUser()
t.setUser();
document.body.addEventListener('keydown', t.handleKeyDown)
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);
window.addEventListener('resize', resizeAllMasonryItems);
}
componentDidUpdate() {
this.triggerMasonry()
this.triggerMasonry();
}
componentWillUnmount() {
document.body.removeEventListener('keydown', this.handleKeyDown)
document.body.removeEventListener('keydown', this.handleKeyDown);
}
addTag = (e, tag) => {
@ -75,12 +83,12 @@ export class Listings extends Component {
const { query, tags, category } = this.state;
const newTags = tags;
if (newTags.indexOf(tag) === -1) {
newTags.push(tag)
newTags.push(tag);
}
this.setState({tags: newTags, page: 0, listings: []})
this.listingSearch(query, newTags, category, null)
window.scroll(0,0)
}
this.setState({ tags: newTags, page: 0, listings: [] });
this.listingSearch(query, newTags, category, null);
window.scroll(0, 0);
};
removeTag = (e, tag) => {
e.preventDefault();
@ -90,85 +98,93 @@ export class Listings extends Component {
if (newTags.indexOf(tag) > -1) {
newTags.splice(index, 1);
}
this.setState({tags: newTags, page: 0, listings: []})
this.listingSearch(query, newTags, category, null)
}
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)
}
this.setState({ category: cat, page: 0, listings: [] });
this.listingSearch(query, tags, cat, null);
};
handleKeyDown = (e) => {
handleKeyDown = e => {
// Enable Escape key to close an open listing.
if (this.openedListing !== null && e.key === 'Escape') {
this.handleCloseModal()
this.handleCloseModal();
}
}
};
handleCloseModal = (e) => {
if (e.target.id === 'single-classified-listing-container__inner' ||
handleCloseModal = e => {
if (
e.target.id === 'single-classified-listing-container__inner' ||
e.target.id === 'classified-filters' ||
e.target.id === 'classified-listings-modal-background' ) {
e.target.id === 'classified-listings-modal-background'
) {
const { query, tags, category } = this.state;
this.setState({openedListing: null, page: 0})
this.setState({ openedListing: null, page: 0 });
this.setLocation(query, tags, category, null);
document.body.classList.remove('modal-open');
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.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) => {
handleDraftingMessage = e => {
e.preventDefault();
this.setState({ message: e.target.value })
}
this.setState({ message: e.target.value });
};
handleSubmitMessage = (e) => {
handleSubmitMessage = e => {
e.preventDefault();
const { message, openedListing} = this.state
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('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;
});
}
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)
}
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.setState({ query: '', page: 0, listings: [] });
this.listingSearch('', tags, category, null);
}
};
getQueryParams = () => {
let qs = document.location.search;
@ -179,83 +195,96 @@ export class Listings extends Component {
const re = /[?&]?([^=]+)=([^&]*)/g;
// eslint-disable-next-line no-cond-assign
while (tokens = re.exec(qs)) {
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.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 });
t.setState({ currentUserId: window.currentUser.id });
}
}, 300)
}, 300);
setTimeout(function() {
if (window.currentUser && t.state.currentUserId === null) {
t.setState({currentUserId: window.currentUser.id });
t.setState({ currentUserId: window.currentUser.id });
}
}, 1000)
}
}, 1000);
};
triggerMasonry = () => {
resizeAllMasonryItems();
setTimeout(function() {
resizeAllMasonryItems();
}, 1)
}, 1);
setTimeout(function() {
resizeAllMasonryItems();
}, 3)
}
}, 3);
};
setLocation = (query, tags, category, slug) => {
let newLocation = ''
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){
} 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'
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}
const filterObject = { tagFilters: tags, hitsPerPage: 75, page };
if (category.length > 0) {
filterObject.filters = `category:${category}`
filterObject.filters = `category:${category}`;
}
index.search(query, filterObject)
.then(function searchDone(content) {
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 (!listings.map(l => l.id).includes(listing.id)) {
fullListings.push(listing);
}
});
t.setState({listings: fullListings, initialFetch: false, showNextPageButt: content.hits.length === 75});
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 {
listings,
query,
tags,
category,
allCategories,
currentUserId,
openedListing,
showNextPageButt,
initialFetch,
} = this.state;
const allListings = listings.map(listing => (
<SingleListing
onAddTag={this.addTag}
@ -268,55 +297,152 @@ export class Listings extends Component {
));
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>
<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>
<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>
))
<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 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> : '';
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) {
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>
<form
id="listings-message-form"
className="listings-contact-via-connect"
onSubmit={this.handleSubmitMessage}
>
<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>
<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>
<form
id="listings-message-form"
className="listings-contact-via-connect"
>
<p>
<em>All private interactions <b>must</b> abide by the <a href="/code-of-conduct">code of conduct</a></em>
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}>
<div
id="single-classified-listing-container__inner"
className="single-classified-listing-container__inner"
onClick={this.handleCloseModal}
>
<SingleListing
onAddTag={this.addTag}
onChangeCategory={this.selectCategory}
@ -326,10 +452,10 @@ export class Listings extends Component {
isOpen
/>
{messageModal}
<div className="single-classified-listing-container__spacer"></div>
<div className="single-classified-listing-container__spacer" />
</div>
</div>
)
);
}
if (initialFetch) {
this.triggerMasonry();
@ -339,13 +465,31 @@ export class Listings extends Component {
{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>
<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>
<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)} />
<input
type="text"
placeholder="search"
id="listings-search"
autoComplete="off"
defaultValue={query}
onKeyUp={e => this.handleQuery(e)}
/>
{clearQueryButton}
{selectedTags}
</div>
@ -356,23 +500,31 @@ export class Listings extends Component {
{nextPageButt}
{modal}
</div>
)
);
}
}
function resizeMasonryItem(item){
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 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));
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}`;
item.style.gridRowEnd = `span ${rowSpan}`;
}
function resizeAllMasonryItems(){
function resizeAllMasonryItems() {
// Get all item class objects in one list
const allItems = document.getElementsByClassName('single-classified-listing');
@ -381,12 +533,9 @@ function resizeAllMasonryItems(){
* each list-item (i.e. each masonry item)
*/
// eslint-disable-next-line vars-on-top
for(let i=0;i<allItems.length;i++){
for (let i = 0; i < allItems.length; i++) {
resizeMasonryItem(allItems[i]);
}
}
Listings.displayName = 'Classified Listings';

View file

@ -22,6 +22,7 @@ class Article < ApplicationRecord
counter_culture :organization
has_many :comments, as: :commentable, inverse_of: :commentable
has_many :profile_pins, as: :pinnable, inverse_of: :pinnable
has_many :buffer_updates, dependent: :destroy
has_many :notifications, as: :notifiable, inverse_of: :notifiable, dependent: :destroy
has_many :notification_subscriptions, as: :notifiable, inverse_of: :notifiable, dependent: :destroy

View file

@ -14,6 +14,7 @@ class Organization < ApplicationRecord
has_many :credits
has_many :unspent_credits, -> { where spent: false }, class_name: "Credit", inverse_of: :organization
has_many :classified_listings
has_many :profile_pins, as: :profile, inverse_of: :profile
validates :name, :summary, :url, :profile_image, presence: true
validates :name,

21
app/models/profile_pin.rb Normal file
View file

@ -0,0 +1,21 @@
class ProfilePin < ApplicationRecord
belongs_to :pinnable, polymorphic: true
belongs_to :profile, polymorphic: true
validates :profile_id, presence: true
validates :profile_type, inclusion: { in: %w[User] } # Future could be organization, tag, etc.
validates :pinnable_id, presence: true, uniqueness: { scope: %i[profile_id profile_type pinnable_type]}
validates :pinnable_type, inclusion: { in: %w[Article] } # Future could be comments, etc.
validate :only_five_pins_per_profile
validate :pinnable_belongs_to_profile
private
def only_five_pins_per_profile
errors.add(:base, "cannot have more than five total pinned posts") if profile.profile_pins.size > 5
end
def pinnable_belongs_to_profile
errors.add(:pinnable_id, "must have proper premissions for pin") if pinnable.user_id != profile_id
end
end

View file

@ -27,6 +27,7 @@ class User < ApplicationRecord
has_many :mentions, dependent: :destroy
has_many :messages, dependent: :destroy
has_many :notes, as: :noteable, inverse_of: :noteable
has_many :profile_pins, as: :profile, inverse_of: :profile
has_many :authored_notes, as: :author, inverse_of: :author, class_name: "Note"
has_many :notifications, dependent: :destroy
has_many :reactions, dependent: :destroy

View file

@ -28,8 +28,7 @@
</a>
<h4>
<a href="/<%= story.cached_user.username %>">
<%= story.cached_user.name %>・<time datetime="<%= story.published_timestamp %>"><%= story.readable_publish_date %></time>
<span class="time-ago-indicator-initial-placeholder" data-seconds="<%= story.published_at_int %>"></span>
<%= story.cached_user.name %>・<time datetime="<%= story.published_timestamp %>"><%= story.readable_publish_date %></time><span class="time-ago-indicator-initial-placeholder" data-seconds="<%= story.published_at_int %>"></span>
</a>
</h4>
<div class="tags">

View file

@ -4,6 +4,12 @@
<div class="dashboard-manage-nav">
<a href="/dashboard">Dashboard</a> 👉 Manage Your Post
</div>
<% if flash[:pins_error] %>
<h3 class="manage-page-error">Uh Oh! <%= flash[:pins_error] %> 😬</h3>
<% end %>
<% if flash[:pins_success] %>
<h3 class="manage-page-success"><%= flash[:pins_success] %></h3>
<% end %>
<h2>Tools:</h2>
<p><strong>Suggest a Tweet:</strong> Help get your post in front of more developers by suggesting a tweet that <a href="https://twitter.com/<%= ApplicationConfig["SITE_TWITTER_HANDLE"] %>">@<%= ApplicationConfig["SITE_TWITTER_HANDLE"] %></a> editors can use.
<p><strong>Experience Level of Post:</strong> The best target dev experience level— a <em>gentle</em> indicator to help show the post to the people who will benefit the most.

View file

@ -37,6 +37,16 @@
<a href="<%= article.path %>/edit" class="pill cta green">EDIT</a>
<% if manage_view %>
<a href="<%= article.path %>/delete_confirm" data-no-instant class="cta pill black">DELETE</a>
<% if article.profile_pins.any? %>
<%= form_for(article.profile_pins.first) do |f| %>
<button class="pill cta">UNPIN</button>
<% end %>
<% else %>
<%= form_for(ProfilePin.new) do |f| %>
<%= f.hidden_field :pinnable_id, value: article.id %>
<button class="pill cta">PIN TO PROFILE</button>
<% end %>
<% end %>
<% elsif article.published %>
<a href="<%= article.path %>/manage" class="cta pill black">MANAGE</a>
<% else %>

View file

@ -1,3 +1,11 @@
<% if @pinned_stories.any? %>
<div class="pinned-articles">
<div class="pinned-articles-indicator"><img src="<%= asset_path("pushpin.svg") %>" alt="pin" /> Pinned</div>
<% @pinned_stories.each do |story| %>
<%= render "articles/single_story", story: story %>
<% end %>
</div>
<% end %>
<% @stories.each_with_index do |story, i| %>
<%= render "articles/single_story", story: story %>
<% if i == 0 && @comments.any? %>

View file

@ -145,6 +145,7 @@ Rails.application.routes.draw do
resources :reading_list_items, only: [:update]
resources :poll_votes, only: %i[show create]
resources :poll_skips, only: [:create]
resources :profile_pins, only: %i[create update]
get "/chat_channel_memberships/find_by_chat_channel_id" => "chat_channel_memberships#find_by_chat_channel_id"
get "/credits/purchase" => "credits#new"

View file

@ -0,0 +1,13 @@
class CreateProfilePins < ActiveRecord::Migration[5.2]
def change
create_table :profile_pins do |t|
t.bigint :profile_id
t.bigint :pinnable_id
t.string :profile_type
t.string :pinnable_type
t.timestamps
end
add_index("profile_pins", "profile_id")
add_index("profile_pins", "pinnable_id")
end
end

View file

@ -715,6 +715,17 @@ ActiveRecord::Schema.define(version: 2019_06_19_153428) do
t.datetime "updated_at", null: false
end
create_table "profile_pins", force: :cascade do |t|
t.datetime "created_at", null: false
t.bigint "pinnable_id"
t.string "pinnable_type"
t.bigint "profile_id"
t.string "profile_type"
t.datetime "updated_at", null: false
t.index ["pinnable_id"], name: "index_profile_pins_on_pinnable_id"
t.index ["profile_id"], name: "index_profile_pins_on_profile_id"
end
create_table "push_notification_subscriptions", force: :cascade do |t|
t.string "auth_key"
t.datetime "created_at", null: false
@ -884,6 +895,7 @@ ActiveRecord::Schema.define(version: 2019_06_19_153428) do
t.text "base_cover_letter"
t.string "behance_url"
t.string "bg_color_hex"
t.text "cached_chat_channel_memberships"
t.boolean "checked_code_of_conduct", default: false
t.integer "comments_count", default: 0, null: false
t.string "config_font", default: "default"
@ -1000,6 +1012,7 @@ ActiveRecord::Schema.define(version: 2019_06_19_153428) do
t.string "stackoverflow_url"
t.string "stripe_id_code"
t.text "summary"
t.text "summary_html"
t.string "tabs_or_spaces"
t.string "text_color_hex"
t.string "text_only_name"

View file

@ -0,0 +1,6 @@
FactoryBot.define do
factory :profile_pin do
pinnable_type { "Article" }
profile_type { "User" }
end
end

View file

@ -0,0 +1,44 @@
require 'rails_helper'
RSpec.describe ProfilePin, type: :model do
let(:user) { create(:user) }
let(:second_user) { create(:user) }
let(:article) { create(:article, user_id: user.id) }
let(:second_article) { create(:article, user_id: user.id) }
let(:third_article) { create(:article, user_id: user.id) }
let(:fourth_article) { create(:article, user_id: user.id) }
let(:fifth_article) { create(:article, user_id: user.id) }
let(:sixth_article) { create(:article, user_id: user.id) }
describe "validations" do
it "allows up to five pins per user" do
create(:profile_pin, pinnable_id: article.id, profile_id: user.id)
create(:profile_pin, pinnable_id: second_article.id, profile_id: user.id)
create(:profile_pin, pinnable_id: third_article.id, profile_id: user.id)
create(:profile_pin, pinnable_id: fourth_article.id, profile_id: user.id)
last_pin = create(:profile_pin, pinnable_id: fifth_article.id, profile_id: user.id)
expect(last_pin).to be_valid
end
it "disallows the sixth pin" do
create(:profile_pin, pinnable_id: article.id, profile_id: user.id)
create(:profile_pin, pinnable_id: second_article.id, profile_id: user.id)
create(:profile_pin, pinnable_id: third_article.id, profile_id: user.id)
create(:profile_pin, pinnable_id: fourth_article.id, profile_id: user.id)
create(:profile_pin, pinnable_id: fifth_article.id, profile_id: user.id)
last_pin = create(:profile_pin, pinnable_id: sixth_article.id, profile_id: user.id)
expect(last_pin).not_to be_valid
end
it "ensures pinnable belongs to profile" do
pin = build(:profile_pin, pinnable_id: article.id, profile_id: second_user.id)
expect(pin).not_to be_valid
end
it "ensures one pin per pinnable per profile" do
create(:profile_pin, pinnable_id: article.id, profile_id: user.id)
last_pin = build(:profile_pin, pinnable_id: article.id, profile_id: user.id)
expect(last_pin).not_to be_valid
end
end
end

View file

@ -0,0 +1,25 @@
require "rails_helper"
RSpec.describe "ProfilePins", type: :request do
let(:user) { create(:user) }
let(:article) { create(:article, user_id: user.id) }
before { sign_in user }
describe "POST /profile_pins" do
it "creates a pin" do
post "/profile_pins", params: {
profile_pin: { pinnable_id: article.id }
}
expect(ProfilePin.last.pinnable_id).to eq(article.id)
end
end
describe "PUT /profile_pins/:id" do
it "votes on behalf of current user" do
profile_pin = create(:profile_pin, pinnable_id: article.id, profile_id: user.id)
put "/profile_pins/#{profile_pin.id}"
expect(ProfilePin.all.size).to eq(0)
end
end
end

View file

@ -10,6 +10,20 @@ RSpec.describe "UserProfiles", type: :request do
expect(response.body).to include CGI.escapeHTML(user.name)
end
it "renders pins if any" do
create(:article, user_id: user.id)
create(:article, user_id: user.id)
last_article = create(:article, user_id: user.id)
create(:profile_pin, pinnable_id: last_article.id, profile_id: user.id)
get "/#{user.username}"
expect(response.body).to include "Pinned"
end
it "does not render pins if they don't exist" do
get "/#{user.username}"
expect(response.body).not_to include "Pinned"
end
it "renders profile page of user after changed username" do
old_username = user.username
user.update(username: "new_username_yo_#{rand(10_000)}")