Listings Dashboard (#3014)

* init wip react components

* wip add dashboard to listing views

- create a html.erb view to hold listing/dashboard with javascript_pack_tag
- create listingDashboard.jsx in packs
- create listingDashboard component and dashboard folder with smaller components
- added view to controller and routes

* slowly adding some formatting

adjusted styling, info

* remove listing body from dashboard

* add some styling and adjust info

* wip adding org listings

* wip multi org in dashboard

* adjust for multi org

* add styling and better multi org support

* comment out bump and delete -- save for upcoming PR

* reduce lines

* listing form has link to listings dashboard instead of listings

* adjust header boxes flex direction for mobile

* fix organization id query

* WIP testing

needs more rigorous tests

* finalize tests
This commit is contained in:
Mario See 2019-06-18 14:52:59 -04:00 committed by Ben Halpern
parent bd3125bcfd
commit 9e616de9f8
11 changed files with 610 additions and 7 deletions

View file

@ -574,3 +574,206 @@ form.listings-contact-via-connect {
background: rgba(0, 0, 0, 0.8);
user-select: none;
}
.dashboard-listings-container {
padding-top: 80px;
padding-bottom: 80px;
width: 800px;
max-width: 96%;
margin: auto;
.rounded-btn {
font-size: calc(15px + 0.2vw);
font-family: $helvetica-condensed;
font-stretch: condensed;
padding: 4px 20px;
border-radius: 100px;
margin-right: 5px;
display: inline-block;
width: fit-content;
margin-bottom: 10px;
@include themeable(
color,
theme-color,
$black
);
@include themeable(
background,
theme-container-background,
lighten($light-medium-gray, 5%)
);
&:hover {
@include themeable(
background,
theme-container-background-hover,
$light-medium-gray
);
}
&.active {
@include themeable(
background,
theme-secondary-color,
$dark-gray
);
@include themeable(
color,
theme-container-background,
white
);
}
}
.dashboard-listings-header-wrapper {
display: flex;
flex-direction: row;
text-align: center;
align-items: center;
justify-content: center;
margin-bottom: 15px;
background: $dark-gray;
@include themeable(box-shadow, theme-container-box-shadow, $bold-shadow);
padding: 15px 0px;
@media screen and (max-width: 768px) {
flex-direction: column;
}
.dashboard-listings-header {
width: 43%;
text-align: center;
color: white;
margin: 5px auto;
font-size: calc(1.1em + 0.1vw);
padding: 18px 0px 7px;
padding-left: 10px;
font-family: $monospace;
border: 1px solid $light-gray;
display: block;
text-align: center;
margin: 1.2%;
padding: 1.2%;
padding-bottom: 30px;
@media screen and (max-width: 768px) {
width: 90%;
}
a {
font-size: 0.9em;
border: 2px solid #2ad7c5;
color: #25c2b2;
padding: 6px 16px;
margin-top: 6px;
border-radius: 100px;
}
}
}
.dashboard-listings-view {
.dashboard-listing-row {
border: var(--theme-container-border, 1px solid #dbdbdb);
box-shadow: var(--theme-container-box-shadow, 1px 1px 0px #c2c2c2);
padding: 5px 15px;
margin-bottom: 13px;
border-radius: 3px;
position: relative;
background: var(--theme-container-background, #fff);
.listing-org {
@include themeable(
background,
theme-secondary-color,
darken($light-gray, 6%)
);
@include themeable(color, theme-container-background, #fff);
margin-right: 5px;
padding: 3px 10px;
border-radius: 100px;
margin-top: 10px;
display: inline-block;
font-size: 0.9em;
}
h2 {
color: var(--theme-container-color, #0a0a0a);
font-weight: 500;
margin-bottom: 10px;
margin-top: 8px;
font-size: 22px;
width: 100%;
@media screen and (min-width: 800px) {
font-size: 34px;
}
}
.dashboard-listing-date {
font-size: 0.85em;
padding: 3px 0px 6px;
color: var(--theme-secondary-color, #666666);
}
.dashboard-listing-category {
padding: 10px 15px;
a {
@include themeable(
color,
theme-secondary-color,
$dark-medium-gray
);
@include themeable(
background,
theme-container-accent-background,
$light-medium-gray
);
display: inline-block;
margin: 2px;
padding: 2px 7px;
font-size: 0.77em;
border-radius: 3px;
font-weight: bold;
}
}
.dashboard-listing-tags {
font-size: 0.85em;
padding: 3px 0px 6px;
a {
color: var(--theme-container-color, #0a0a0a);
}
}
.listing-row-actions {
padding: 7px 0px 10px;
.pill {
background: $bold-blue;
padding: 4px 20px;
border-radius: 100px;
color: white;
margin-right: 5px;
font-size: 14px;
border: 2px solid transparent;
margin-top: 6px;
display: inline-block;
&.green {
background: $green;
color: $black;
}
&.red {
background: $red;
}
&.black {
background: $dark-gray;
}
}
}
}
}
}

View file

@ -3,7 +3,7 @@ class ClassifiedListingsController < ApplicationController
before_action :set_classified_listing, only: %i[edit update]
before_action :set_cache_control_headers, only: %i[index]
after_action :verify_authorized, only: %i[edit update]
before_action :authenticate_user!, only: %i[edit update new]
before_action :authenticate_user!, only: %i[edit update new dashboard]
def index
@displayed_classified_listing = ClassifiedListing.find_by!(slug: params[:slug]) if params[:slug]
@ -82,6 +82,13 @@ class ClassifiedListingsController < ApplicationController
redirect_to "/listings"
end
def dashboard
@classified_listings = ClassifiedListing.where(user_id: current_user.id)
@orgs = Organization.where(id: current_user.organization_memberships.where(type_of_user: "admin").map(&:organization_id))
@org_listings = ClassifiedListing.where(organization_id: current_user.organization_memberships.where(type_of_user: "admin").map(&:organization_id))
@user_credits = current_user.unspent_credits_count
end
private
def mod_page

View file

@ -0,0 +1,134 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<ListingDashboard /> should load listing dashboard 1`] = `
preact-render-spy (1 nodes)
-------
<div class="dashboard-listings-container">
<span
onClick={[Function onClick]}
class="rounded-btn active"
>
Personal
</span>
<span
onClick={[Function onClick]}
class="rounded-btn "
>
Yoyodyne
</span>
<span
onClick={[Function onClick]}
class="rounded-btn "
>
Infotrode
</span>
<div class="dashboard-listings-header-wrapper">
<div class="dashboard-listings-header">
<h3>Listings</h3>
<h4>Listings Made: 3</h4>
<a href="/listings/new">Create a Listing</a>
</div>
<div class="dashboard-listings-header">
<h3>Credits</h3>
<h4>Credits Available: 3</h4>
<a
href="/credits/purchase"
data-no-instant={true}
>
Buy Credits
</a>
</div>
</div>
<div class="dashboard-listings-view">
<div class="dashboard-listing-row">
<a href="cfp/asdfasdf-2ea8">
<h2>asdfasdf</h2>
</a>
<span class="dashboard-listing-date">Jun 11 </span>
<span class="dashboard-listing-category">
<a href="/listings/cfp/">cfp</a>
</span>
<span class="dashboard-listing-tags">
<a
href="/listings?t=computerscience"
data-no-instant={true}
>
#computerscience
</a>
<a
href="/listings?t=career"
data-no-instant={true}
>
#career
</a>
</span>
<div class="listing-row-actions">
<a
href="/listings/23/edit"
class="dashboard-listing-edit-button cta pill green"
>
EDIT
</a>
</div>
</div>
<div class="dashboard-listing-row">
<span class="listing-org">Yoyodyne</span>
<a href="events/yoyoyoyoyoooooooo-4jcb">
<h2>YOYOYOYOYOOOOOOOO</h2>
</a>
<span class="dashboard-listing-date">Jun 11 </span>
<span class="dashboard-listing-category">
<a href="/listings/events/">events</a>
</span>
<span class="dashboard-listing-tags">
<a
href="/listings?t=computerscience"
data-no-instant={true}
>
#computerscience
</a>
<a
href="/listings?t=conference"
data-no-instant={true}
>
#conference
</a>
<a
href="/listings?t=career"
data-no-instant={true}
>
#career
</a>
</span>
<div class="listing-row-actions">
<a
href="/listings/24/edit"
class="dashboard-listing-edit-button cta pill green"
>
EDIT
</a>
</div>
</div>
<div class="dashboard-listing-row">
<span class="listing-org">Infotrode</span>
<a href="cfp/hehhehe-5hld">
<h2>hehhehe</h2>
</a>
<span class="dashboard-listing-date">Jun 11 </span>
<span class="dashboard-listing-category">
<a href="/listings/cfp/">cfp</a>
</span>
<span class="dashboard-listing-tags"></span>
<div class="listing-row-actions">
<a
href="/listings/25/edit"
class="dashboard-listing-edit-button cta pill green"
>
EDIT
</a>
</div>
</div>
</div>
</div>
`;

View file

@ -0,0 +1,113 @@
import { h } from 'preact';
import { deep } from 'preact-render-spy';
import { JSDOM } from 'jsdom';
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>`;
global.window = doc.defaultView;
const listingState = { listings:
[ { 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: [Array],
author: [Object] },
{ 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: [Array],
author: [Object] },
{ 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: [Object] } ],
orgListings:
[ { 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: [Array],
author: [Object] },
{ 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: [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 />);
expect(context.component()).toBeInstanceOf(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)
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').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');
})
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());
})
})
})

View file

@ -0,0 +1,43 @@
import PropTypes from 'prop-types';
import { h } from 'preact';
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>) : '';
return(
<div className="dashboard-listing-row">
{orgName(listing)}
<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-tags">{tagLinks}</span>
<div className="listing-row-actions">
{/* <a className="dashboard-listing-bump-button cta pill black">BUMP</a> */}
<a href={`/listings/${listing.id}/edit`} className="dashboard-listing-edit-button cta pill green">EDIT</a>
{/* <a className="dashboard-listing-delete-button cta pill red">DELETE</a> */}
</div>
</div>
);
}
ListingRow.propTypes = {
listing: PropTypes.object.isRequired,
};

View file

@ -0,0 +1,69 @@
import { h, Component } from 'preact';
import { ListingRow } from './dashboard/listingRow';
export class ListingDashboard extends Component {
state = {
listings: [],
orgListings: [],
orgs: [],
userCredits: 0,
selectedListings: "user",
}
componentDidMount() {
const t = this;
const container = document.getElementById('classifieds-listings-dashboard')
let listings = [];
let orgs = [];
let orgListings = [];
listings = JSON.parse(container.dataset.listings);
orgs = JSON.parse(container.dataset.orgs);
orgListings = JSON.parse(container.dataset.orglistings);
const userCredits = container.dataset.usercredits;
t.setState({ listings, orgListings, orgs, userCredits });
}
render() {
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} /> : '');
}
const orgButtons = orgs.map(org => (
<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>);
}
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 (
<div className="dashboard-listings-container">
<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>
</div>
<div className="dashboard-listings-header">
<h3>Credits</h3>
{creditCount(selectedListings, userCredits, orgs)}
<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

@ -22,7 +22,7 @@ export class Listings extends Component {
const params = this.getQueryParams();
const t = this;
const algoliaId = document.querySelector("meta[name='algolia-public-id']")
.content;
.content;
const algoliaKey = document.querySelector("meta[name='algolia-public-key']")
.content;
const env = document.querySelector("meta[name='environment']").content;
@ -52,9 +52,9 @@ export class Listings extends Component {
t.setUser()
document.body.addEventListener('keydown', t.handleKeyDown)
/*
The width of the columns also changes when the browser is resized
/*
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
*/
@ -342,6 +342,7 @@ export class Listings extends Component {
<a href="/listings" className={category === '' ? 'selected' : ''} onClick={e => this.selectCategory(e, '')} data-no-instant>all</a>
{categoryLinks}
<a href='/listings/new' className='classified-create-link'>Create a Listing</a>
<a href='/listings/dashboard' className='classified-create-link'>Manage Listings</a>
</div>
<div className="classified-filters-tags" id="classified-filters-tags">
<input type="text" placeholder="search" id="listings-search" autoComplete="off" defaultValue={query} onKeyUp={e => this.handleQuery(e)} />

View file

@ -0,0 +1,15 @@
import { h, render } from 'preact';
import { ListingDashboard } from '../listings/listingDashboard';
function loadElement() {
const root = document.getElementById('classifieds-listings-dashboard');
if (root) {
render(<ListingDashboard />, root, root.firstElementChild);
}
}
window.InstantClick.on('change', () => {
loadElement();
});
loadElement();

View file

@ -1,6 +1,6 @@
<%= form_with(model: classified_listing, local: true) do |form| %>
<header>
<a href="/listings" class="listings-back-button">&lt; go back to listings</a>
<a href="/listings/dashboard" class="listings-back-button">&lt; return to listings dashboard</a>
<h2>Create a Community Listing</h2>
</header>
<div class="classified-form-inner">

View file

@ -0,0 +1,17 @@
<div id="classifieds-listings-dashboard" data-listings="<%= @classified_listings.to_json(
only: %i[title tag_list bumped_at updated_at category id user_id slug organization_id],
include: {
author: { only: %i[username name], methods: %i[username profile_image_90] },
},
)%>" data-usercredits="<%= @user_credits %>"
data-orglistings="<%= @org_listings.to_json(
only: %i[title tag_list bumped_at updated_at category id user_id slug organization_id],
include: {
author: { only: %i[username name], methods: %i[username profile_image_90] },
},
) %>" data-orgs="<%= @orgs.to_json(
only: %i[id name slug unspent_credits_count]
) %>" >
</div>
<%= javascript_pack_tag "listingDashboard", defer: true %>

View file

@ -139,7 +139,7 @@ Rails.application.routes.draw do
resources :tag_adjustments, only: [:create]
resources :rating_votes, only: [:create]
resources :page_views, only: %i[create update]
resources :classified_listings, path: :listings, only: %i[index new create edit update delete]
resources :classified_listings, path: :listings, only: %i[index new create edit update delete dashboard]
resources :credits, only: %i[index new create]
resources :buffer_updates, only: [:create]
resources :reading_list_items, only: [:update]
@ -148,6 +148,7 @@ Rails.application.routes.draw do
get "/chat_channel_memberships/find_by_chat_channel_id" => "chat_channel_memberships#find_by_chat_channel_id"
get "/credits/purchase" => "credits#new"
get "/listings/dashboard" => "classified_listings#dashboard"
get "/listings/:category" => "classified_listings#index"
get "/listings/:category/:slug" => "classified_listings#index"
get "/listings/:category/:slug/:view" => "classified_listings#index",