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:
parent
0267d4e829
commit
dcbb188cf1
25 changed files with 827 additions and 203 deletions
1
app/assets/images/pushpin.svg
Normal file
1
app/assets/images/pushpin.svg
Normal 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 |
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
30
app/controllers/profile_pins_controller.rb
Normal file
30
app/controllers/profile_pins_controller.rb
Normal 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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -184,9 +184,8 @@ min read・
|
|||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return '';
|
||||
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
render() {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
21
app/models/profile_pin.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 %>
|
||||
|
|
|
|||
|
|
@ -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? %>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
13
db/migrate/20190616053854_create_profile_pins.rb
Normal file
13
db/migrate/20190616053854_create_profile_pins.rb
Normal 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
|
||||
13
db/schema.rb
13
db/schema.rb
|
|
@ -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"
|
||||
|
|
|
|||
6
spec/factories/profile_pins.rb
Normal file
6
spec/factories/profile_pins.rb
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
FactoryBot.define do
|
||||
factory :profile_pin do
|
||||
pinnable_type { "Article" }
|
||||
profile_type { "User" }
|
||||
end
|
||||
end
|
||||
44
spec/models/profile_pin_spec.rb
Normal file
44
spec/models/profile_pin_spec.rb
Normal 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
|
||||
25
spec/requests/profile_pins_spec.rb
Normal file
25
spec/requests/profile_pins_spec.rb
Normal 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
|
||||
|
|
@ -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)}")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue