Listings redesign (#9349)

* flare tag line height

* .

* dropdown fix + actions bar fix

* actions bar on mob

* .

* css commented out

* .

* button fix

* preload adjustments

* tags

* Fixed listings modal to make it semantic HTML via <dialog />.

* Refactor an a11y fix.

* Removed test that is no longer needed.

* Added missing global function for test..

* Fixed broken <ListingFiltersCategories /> tests.

* Refactor of mobile dropdown for listing categories.

* Updated a TODO.

* Made the clear query button a crayons Preact button.

* padding

* Fixed listings modal for accessiblilty.

* Removed rspecs that are no longer valid.

* We're no longer using the <dialog /> element, at least for now, so CSS changes aren't necessary.

* Wasn't supposed to be committed.

Co-authored-by: Nick Taylor <nick@dev.to>
Co-authored-by: rhymes <rhymes@hey.com>
Co-authored-by: Nick Taylor <nick@iamdeveloper.com>
This commit is contained in:
ludwiczakpawel 2020-07-30 23:53:35 +02:00 committed by GitHub
parent 11820aa29b
commit 313cd61ee3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 624 additions and 824 deletions

View file

@ -28,4 +28,5 @@
@import 'views/article';
@import 'views/article-form';
@import 'views/listings';
@import 'views/dashboard';

View file

@ -2,292 +2,163 @@
@import 'mixins';
.listings-container {
margin: 24px 0;
text-align: left;
max-width: 100%;
min-height: 100vh;
.listing-filters {
padding: 0 16px;
a {
padding: 3px 16px;
border-radius: 100px;
display: inline-block;
font-size: 15px;
&.selected {
background: $bold-blue;
color: white;
&.listings-form-container {
form {
margin: 60px auto 0px;
width: 100%;
max-width: 580px;
background: var(--card-bg);
box-sizing: border-box;
border: 1px solid var(--card-color-tertiary);
@media screen and (min-width: 580px) {
border-radius: 8px;
margin: 20px auto 20px;
}
&.new-listing-button {
color: $black;
background: $green;
margin: 0px 4px;
* {
box-sizing: border-box;
}
}
.listing-filters-categories {
a {
display: block;
padding: 7px 16px;
&.listing-create-link {
border: 2px solid darken($green, 14%);
color: darken($green, 19%);
padding: 6px 16px;
margin-top: 6px;
header {
padding: 25px 22px;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
}
.listing-form-inner,
.listings-form-inner {
padding: 0px 15px 40px;
@media screen and (min-width: 580px) {
padding: 0px 15px 18px;
}
}
@media screen and (min-width: 550px) {
a {
display: inline-block;
margin: 0 8px 8px 0;
border: 1px solid $bold-blue;
min-width: 50px;
text-align: center;
padding: 3px 16px;
&.listing-create-link {
padding: 2px 16px;
}
}
}
}
.listing-filters-tags {
display: flex;
padding: 20px 0px;
position: relative;
input {
.listing-back-button {
font-size: 0.8em;
margin: 2px 0px 8px;
display: block;
}
h2 {
font-size: 1.45em;
color: var(--accent-brand);
margin: 0px;
}
.listing-errors {
background: $red;
color: $black;
padding: 20px;
border-radius: 3px;
h2 {
color: $black;
}
}
label {
display: inline-block;
font-size: 0.7em;
margin: 12px 0px 3px;
border-radius: 3px;
padding: 6px 0px;
font-weight: bold;
}
input {
width: 100%;
font-size: 18px;
padding: 10px 7px;
border-radius: 3px;
border: 1px solid var(--form-border);
background: var(--form-bg);
color: var(--body-color);
@include themeable(
background,
theme-container-accent-background,
$light-gray
&[type='checkbox'] {
width: 100px;
}
}
textarea {
width: 100%;
height: 300px;
padding: 5px;
font-size: 17px;
border-radius: 3px;
background: var(--form-bg);
color: var(--body-color);
border: 1px solid var(--form-border);
}
select {
width: 100%;
font-size: 20px;
}
details {
cursor: pointer;
font-size: 0.8em;
padding: 8px 0px;
color: var(--card-color-tertiary);
}
.cta-main-listing-form {
border: 0px;
padding: 12px 0px;
border-radius: 3px;
background-color: $dark-purple;
background-image: linear-gradient(
141deg,
$dark-purple 0%,
#1f89db 51%,
#2981e5 75%
);
border-radius: 100px;
padding: 3px 14px;
border: 1px solid $light-medium-gray;
width: calc(100% - 26px);
margin-bottom: 4px;
@media screen and (min-width: 550px) {
width: 280px;
margin-right: 8px;
font-size: 1.4em;
color: white;
margin-top: 20px;
width: 380px;
max-width: 90%;
margin: 35px auto;
display: block;
text-align: center;
@include themeable(
box-shadow,
theme-container-box-shadow,
2px 2px 8px darken($light-medium-gray, 5%)
);
&.cta-draft {
color: $black;
background: $yellow;
}
}
.listing-search-clear {
background: $light-gray;
border: 1px solid $light-medium-gray;
position: absolute;
left: calc(100% - 25px);
top: 24px;
border-radius: 8px;
@media screen and (min-width: 550px) {
left: 282px;
}
}
.listing-tag {
font-size: 0.7em;
color: $black;
border-radius: 100px;
display: inline-block;
background: $dark-gray;
margin-right: 5px;
a {
padding: 0px 8px;
}
&.clear-all {
background: $light-medium-gray;
a {
color: $black;
}
}
span {
color: white;
padding: 3px 2px;
.listings-current-credits {
position: fixed;
bottom: 0px;
left: 0px;
right: 0px;
padding: 18px 0px;
background-color: #aecfef;
background-image: linear-gradient(
141deg,
#aecfef 0%,
#ddeeff 51%,
#c8ebf1 75%
);
color: #32325d;
text-align: center;
z-index: 20;
font-size: 0.85em;
.listings-current-credits-inner {
display: inline-block;
&.tag-name {
padding-right: 0px;
margin: 20px;
select {
width: initial;
}
}
}
}
}
.listings-columns {
display: grid;
grid-gap: 24px;
padding: 0 16px;
grid-template-columns: repeat(auto-fill, minmax(330px, 1fr));
grid-auto-rows: 0;
}
form {
margin: 60px auto 0px;
width: 100%;
max-width: 580px;
background: var(--card-bg);
box-sizing: border-box;
border: 1px solid var(--card-color-tertiary);
@media screen and (min-width: 580px) {
border-radius: 8px;
margin: 20px auto 20px;
}
* {
box-sizing: border-box;
}
header {
padding: 25px 22px;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
}
.listing-form-inner {
padding: 0px 15px 40px;
@media screen and (min-width: 580px) {
padding: 0px 15px 18px;
}
}
.listing-back-button {
font-size: 0.8em;
margin: 2px 0px 8px;
display: block;
}
h2 {
font-size: 1.45em;
color: var(--accent-brand);
margin: 0px;
}
.listing-errors {
background: $red;
color: $black;
padding: 20px;
border-radius: 3px;
h2 {
color: $black;
}
}
label {
display: inline-block;
font-size: 0.7em;
margin: 12px 0px 3px;
border-radius: 3px;
padding: 6px 0px;
font-weight: bold;
}
input {
width: 100%;
font-size: 18px;
padding: 10px 7px;
border-radius: 3px;
border: 1px solid var(--form-border);
background: var(--form-bg);
color: var(--body-color);
&[type='checkbox'] {
width: 100px;
}
}
textarea {
width: 100%;
height: 300px;
padding: 5px;
font-size: 17px;
border-radius: 3px;
background: var(--form-bg);
color: var(--body-color);
border: 1px solid var(--form-border);
}
select {
width: 100%;
font-size: 20px;
}
details {
cursor: pointer;
font-size: 0.8em;
padding: 8px 0px;
color: var(--card-color-tertiary);
}
.cta-main-listing-form {
border: 0px;
padding: 12px 0px;
border-radius: 3px;
background-color: $dark-purple;
background-image: linear-gradient(
141deg,
$dark-purple 0%,
#1f89db 51%,
#2981e5 75%
);
border-radius: 100px;
font-size: 1.4em;
color: white;
margin-top: 20px;
width: 380px;
max-width: 90%;
margin: 35px auto;
display: block;
text-align: center;
@include themeable(
box-shadow,
theme-container-box-shadow,
2px 2px 8px darken($light-medium-gray, 5%)
);
&.cta-draft {
color: $black;
background: $yellow;
}
}
.listings-current-credits {
position: fixed;
bottom: 0px;
left: 0px;
right: 0px;
padding: 18px 0px;
background-color: #aecfef;
background-image: linear-gradient(
141deg,
#aecfef 0%,
#ddeeff 51%,
#c8ebf1 75%
);
color: #32325d;
text-align: center;
z-index: 20;
font-size: 0.85em;
.listings-current-credits-inner {
display: inline-block;
margin: 20px;
select {
width: initial;
a {
text-decoration: underline;
display: inline-block;
color: var(--link-brand-color);
}
@media screen and (min-width: 580px) {
font-size: 1.1em;
}
}
a {
text-decoration: underline;
display: inline-block;
color: var(--link-brand-color);
.listings-unpublish {
background: $red;
}
@media screen and (min-width: 580px) {
font-size: 1.1em;
p {
font-size: 0.8em;
color: var(--card-color-tertiary);
}
}
.listings-unpublish {
background: $red;
}
p {
font-size: 0.8em;
color: var(--card-color-tertiary);
}
}
.listings-load-more-button {
text-align: center;
button {
background: var(--theme-container-background);
color: var(--body-color);
border: 1px solid var(--card-color-tertiary);
font-size: 17px;
padding: 14px 5px;
margin: 40px auto 70px;
width: 320px;
max-width: 80%;
border-radius: 100px;
font-weight: bold;
}
}
.field {
font-size: 18px;
}
}
@ -343,230 +214,6 @@
font-weight: bold;
}
.single-listing-container {
width: 100%;
z-index: 20;
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0px;
margin: 12px auto 0px;
overflow: hidden;
.single-listing-container__inner {
position: absolute;
height: 100%;
width: 100%;
overflow-y: auto;
box-sizing: content-box;
padding-right: 17px;
-webkit-overflow-scrolling: touch;
padding-top: 95px;
margin-bottom: 150px;
@media screen and (min-width: 950px) {
left: 0;
right: 0;
box-sizing: border-box;
}
.single-listing-container__spacer {
height: 150px;
margin-bottom: 150px;
}
}
.single-listing-container__inner::-webkit-scrollbar {
display: none;
}
}
.single-listing {
width: 100%;
display: inline-block;
border: 1px solid $light-medium-gray;
box-sizing: border-box;
border-radius: 3px;
overflow: hidden;
text-overflow: ellipsis;
font-size: 0.9em;
break-inside: avoid;
background: var(--card-bg);
h3 {
display: flex;
justify-content: space-between;
margin: 0px;
padding: 9px 3px 9px 12px;
position: relative;
@include themeable(
background,
theme-container-accent-background,
lighten($light-medium-gray, 8%)
);
border-bottom: 1px solid $light-medium-gray;
font-size: 16px;
a {
color: var(--body-color);
}
}
.dropdown-btn {
background: none;
border: none;
cursor: pointer;
.dropdown-icon {
max-width: 15px;
max-height: 15px;
}
}
.dropdown {
position: absolute;
bottom: 0;
right: 0;
display: inline-block;
.dropdown-content {
display: none;
position: absolute;
right: 0;
border: 1px solid $light-medium-gray;
border-radius: 3px;
background: white;
z-index: 20;
width: 130px;
box-shadow: $shadow;
&.showing {
display: block;
}
}
.dropdown-content a {
color: black;
padding: 12px 16px;
width: 98px;
height: 14px;
font-weight: bold;
display: block;
font-size: 14px;
white-space: nowrap;
}
.dropdown-content a:hover {
background-color: #f1f1f1;
}
}
.single-listing-body {
padding: 10px 12px;
font-size: 15px;
.listing-contact-cta {
border: 2px solid $black;
color: $black;
padding: 8px 10px;
text-align: center;
display: block;
border-radius: 200px;
margin: 4px auto;
width: 80%;
}
}
.single-listing-tags {
padding: 10px 15px;
a {
color: var(--card-color-tertiary);
@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;
}
}
.single-listing-author-info {
font-size: 0.69em;
color: var(--card-color-tertiary);
padding: 0px 15px 4px;
a {
color: var(--card-color-tertiary);
display: inline-block;
&.listing-edit-button {
margin-left: 4px;
}
}
}
&.single-listing--opened {
z-index: 20;
display: block;
position: relative;
left: 0px;
right: 0px;
padding-bottom: 12px;
max-width: 800px;
margin: auto;
@media screen and (min-width: 950px) {
h3 {
font-size: calc(20px + 0.05vw);
}
.single-listing-body {
font-size: calc(18px + 0.05vw);
}
}
}
}
.single-listing-info-link {
display: block;
width: 320px;
max-width: 92%;
margin: auto;
padding: 14px 0px;
text-align: center;
border-radius: 100px;
font-weight: bold;
margin: 40px auto 10px;
color: var(--body-color);
border: 3px solid var(--body-color);
background: var(--card-bg);
}
form.listings-contact-via-connect {
border-radius: 3px;
z-index: 20;
position: relative;
padding: 10px 15px;
margin: 12px auto;
width: 100%;
padding-bottom: 12px;
box-shadow: none;
max-width: 100%;
text-align: right;
p {
text-align: left;
}
width: 94%;
max-width: 800px;
textarea#new-message {
height: 100px;
margin: 5px auto 10px auto;
font-size: 15px;
@media screen and (min-width: 950px) {
font-size: calc(18px + 0.05vw);
}
}
.submit-button {
margin: none 10px;
padding: 0.5em;
height: 2em;
width: 4em;
font-size: 1em;
line-height: 1em;
text-align: center;
border-radius: 3px;
border: 0px;
}
}
.listings-modal-background {
z-index: 15;
position: fixed;

View file

@ -0,0 +1,31 @@
@import '../variables';
@import '../config/import';
.listings-columns {
margin-bottom: var(--su-6);
@media (min-width: $breakpoint-m) {
display: grid;
grid-gap: var(--su-4);
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
}
}
.single-listing {
$el: &;
overflow-wrap: anywhere;
word-break: break-word;
overflow: hidden;
&__dropdown {
display: none;
#{$el}:hover & {
display: block;
}
}
&__tags {
min-height: var(--su-4);
}
}

View file

@ -89,7 +89,7 @@ describe('<AllListings />', () => {
// Ensure each listing is present
const titleOptions = {
selector: 'h3 > a',
selector: 'h2 > a',
};
// 1st listings

View file

@ -4,6 +4,10 @@ import { axe } from 'jest-axe';
import ListingFiltersCategories from '../components/ListingFiltersCategories';
describe('<ListingFiltersCategories />', () => {
beforeEach(() => {
global.selectNavigation = jest.fn();
});
const firstCategory = {
slug: 'clojure',
name: 'Clojure',
@ -39,9 +43,8 @@ describe('<ListingFiltersCategories />', () => {
it('should render an all link', () => {
const { getByText } = renderListingFilterCategories();
const allLink = getByText(/all/i);
const allLink = getByText(/all listings/i, { selector: 'a' });
expect(allLink.getAttribute('href')).toEqual('/listings');
expect(allLink.textContent).toEqual('all');
});
it('should be "selected" when there is no category selected', () => {
@ -52,27 +55,13 @@ describe('<ListingFiltersCategories />', () => {
expect(queryByTestId('selected')).toBeDefined();
});
it('should render a create listing link', () => {
const { getByText } = renderListingFilterCategories();
const createListing = getByText(/create a listing/i);
expect(createListing.getAttribute('href')).toContain('/listings/new');
});
it('should render a manage listings link', () => {
const { getByText } = renderListingFilterCategories();
const manageListing = getByText(/manage listings/i);
expect(manageListing.getAttribute('href')).toContain(
'/listings/dashboard',
);
});
});
describe('should render all the categories links', () => {
it('should render the categories name and their respective links', () => {
const { getByText } = renderListingFilterCategories();
categories.forEach((category) => {
const categoryLink = getByText(`${category.name}`);
const categoryLink = getByText(`${category.name}`, { selector: 'a' });
expect(categoryLink.getAttribute('href')).toEqual(
`/listings/${category.slug}`,
);

View file

@ -60,13 +60,6 @@ describe('<MessageModal />', () => {
),
).toBeDefined();
});
it('should show the personalized message about the interactions', () => {
const { getByTestId } = renderMessageModal(listingWithCurrentUserId);
expect(
getByTestId('personal-message-about-interactions').textContent,
).toEqual('All private interactions must abide by the code of conduct');
});
});
describe('When current user is not the author', () => {
@ -86,16 +79,5 @@ describe('<MessageModal />', () => {
),
).toBeDefined();
});
it('should show a generic message about the interactions', () => {
const { getByTestId } = renderMessageModal(
listingWithDifferentCurrentUserId,
);
expect(
getByTestId('generic-message-about-interactions').textContent,
).toEqual(
'Message must be relevant and on-topic with the listing. All private interactions must abide by the code of conduct',
);
});
});
});

View file

@ -20,13 +20,13 @@ describe('<NextPageButton />', () => {
it('should show a button', () => {
const { queryByText } = render(<NextPageButton {...defaultProps} />);
expect(queryByText(/load more listings/i)).toBeDefined();
expect(queryByText(/load more/i)).toBeDefined();
});
it('should call the onclick handler', () => {
const onClick = jest.fn();
const { getByText } = render(<NextPageButton onClick={onClick} />);
const button = getByText(/load more listings/i);
const button = getByText(/load more/i);
button.click();
expect(onClick).toHaveBeenCalledTimes(1);

View file

@ -2,7 +2,7 @@ import { h } from 'preact';
import { axe } from 'jest-axe';
import { render } from '@testing-library/preact';
import SingleListing from '../singleListing';
import { SingleListing } from '../singleListing/SingleListing';
const listing = {
id: 22,

View file

@ -1,6 +1,7 @@
import { h } from 'preact';
import PropTypes from 'prop-types';
import SingleListing from '../singleListing';
import { SingleListing } from '../singleListing/SingleListing';
import NextPageButton from './NextPageButton';
const AllListings = ({
listings,
@ -8,20 +9,26 @@ const AllListings = ({
onChangeCategory,
currentUserId,
onOpenModal,
showNextPageButton,
loadNextPage,
}) => {
return (
<div className="listings-columns" id="listings-results">
{listings.map((listing) => (
<SingleListing
onAddTag={onAddTag}
onChangeCategory={onChangeCategory}
listing={listing}
currentUserId={currentUserId}
onOpenModal={onOpenModal}
isOpen={false}
/>
))}
</div>
<main class="crayons-layout__content">
<div className="listings-columns" id="listings-results">
{listings.map((listing) => (
<SingleListing
onAddTag={onAddTag}
onChangeCategory={onChangeCategory}
listing={listing}
currentUserId={currentUserId}
onOpenModal={onOpenModal}
isOpen={false}
/>
))}
</div>
{showNextPageButton && <NextPageButton onClick={loadNextPage} />}
</main>
);
};
@ -31,6 +38,8 @@ AllListings.propTypes = {
onAddTag: PropTypes.func.isRequired,
onChangeCategory: PropTypes.func.isRequired,
onOpenModal: PropTypes.func.isRequired,
showNextPageButton: PropTypes.bool.isRequired,
loadNextPage: PropTypes.func.isRequired,
};
AllListings.defaultProps = {

View file

@ -14,7 +14,9 @@ const CategoryLinks = ({ categories, onClick, selectedCategory }) => {
<a
href={`/listings/${category.slug}`}
id={`category-link-${category.slug}`}
className={category.slug === selectedCategory ? 'selected' : ''}
className={`crayons-link crayons-link--block ${
category.slug === selectedCategory ? 'crayons-link--current' : ''
}`}
onClick={(e) => onClick(e, category.slug)}
data-no-instant
{...dataTestIdProp}

View file

@ -0,0 +1,52 @@
/*
global selectNavigation
*/
import { h, Component } from 'preact';
import PropTypes from 'prop-types';
export class CategoryLinks extends Component {
componentDidMount() {
selectNavigation('mobile_nav_listings');
}
render() {
const { categories, selectedCategory } = this.props;
return (
<div className="block m:hidden">
<select
id="mobile_nav_listings"
class="crayons-select"
aria-label="Listings"
>
<option value="/listings" selected={selectedCategory === ''}>
All listings
</option>
{categories.map((category) => {
return (
<option
value={`/listings/${category.slug}`}
selected={category.slug === selectedCategory}
>
{category.name}
</option>
);
})}
</select>
</div>
);
}
}
CategoryLinks.propTypes = {
categories: PropTypes.arrayOf(
PropTypes.shape({
slug: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
}),
).isRequired,
selectedCategory: PropTypes.string.isRequired,
};
export default CategoryLinks;

View file

@ -1,16 +1,17 @@
import { h } from 'preact';
import PropTypes from 'prop-types';
import { Button } from '@crayons/Button/Button';
const ClearQueryButton = ({ onClick }) => (
<button
<Button
data-testid="clear-query-button"
type="button"
className="listing-search-clear"
variant="ghost"
className="absolute right-0"
onClick={onClick}
id="clear-query-button"
>
×
</button>
</Button>
);
ClearQueryButton.propTypes = {

View file

@ -16,12 +16,7 @@ const ListingFilters = ({
query,
}) => {
return (
<div className="listing-filters" id="listing-filters">
<ListingFiltersCategories
categories={categories}
category={category}
onClick={onSelectCategory}
/>
<aside className="crayons-layout__sidebar-left">
<ListingFiltersTags
message={message}
onKeyUp={onKeyUp}
@ -31,7 +26,12 @@ const ListingFilters = ({
onKeyPress={onKeyPress}
query={query}
/>
</div>
<ListingFiltersCategories
categories={categories}
category={category}
onClick={onSelectCategory}
/>
</aside>
);
};

View file

@ -1,38 +1,30 @@
import { h } from 'preact';
import PropTypes from 'prop-types';
import CategoryLinks from './CategoryLinks';
import CategoryLinksMobile from './CategoryLinksMobile';
const ListingFiltersCategories = ({ categories, category, onClick }) => (
<div className="listing-filters-categories">
<a
id="listings-link"
href="/listings"
className={category === '' ? 'selected' : ''}
data-testid={category === '' ? 'selected' : ''}
onClick={onClick}
data-no-instant
>
all
</a>
<CategoryLinks
categories={categories}
onClick={onClick}
selectedCategory={category}
/>
<a
id="listings-new-link"
href="/listings/new"
className="listing-create-link"
>
Create a Listing
</a>
<a
id="listings-dashboard-link"
href="/listings/dashboard"
className="listing-create-link"
>
Manage Listings
</a>
<div className="listing-filters px-2 m:px-0" id="listing-filters">
<nav className="hidden m:block">
<a
id="listings-link"
href="/listings"
className={`crayons-link crayons-link--block ${
category === '' ? 'crayons-link--current' : ''
}`}
data-testid={category === '' ? 'selected' : ''}
onClick={onClick}
data-no-instant
>
All listings
</a>
<CategoryLinks
categories={categories}
onClick={onClick}
selectedCategory={category}
/>
</nav>
<CategoryLinksMobile categories={categories} selectedCategory={category} />
</div>
);

View file

@ -15,13 +15,14 @@ const ListingFiltersTags = ({
const shouldRenderClearQueryButton = query.length > 0;
return (
<div className="listing-filters-tags" id="listing-filters-tags">
<div className="relative pb-2 m:pb-6 px-2 m:px-0">
<input
type="text"
placeholder="search"
placeholder="Search..."
id="listings-search"
aria-label="Search listings"
autoComplete="off"
className="crayons-textfield"
defaultValue={message}
onKeyUp={onKeyUp}
/>

View file

@ -1,5 +1,6 @@
import { h } from 'preact';
import PropTypes from 'prop-types';
import { Button } from '@crayons';
const MessageModal = ({
currentUserId,
@ -14,58 +15,48 @@ const MessageModal = ({
<form
data-testid="listings-message-modal"
id="listings-message-form"
className="listings-contact-via-connect"
onSubmit={onSubmit}
>
{isCurrentUserOnListing ? (
<p id="personal-contact-message">
This is your active listing. Any member can contact you via this form.
</p>
) : (
<p>
<b id="generic-contact-message">
Contact
{` ${listing.author.name} `}
via DEV Connect
</b>
</p>
)}
<header className="mb-4">
<h2 className="fs-xl fw-bold lh-tight">Interested?</h2>
{isCurrentUserOnListing ? (
<p className="color-base-70">
This is your active listing. Any member can contact you via this
form.
</p>
) : (
<p className="color-base-70">Message {` ${listing.author.name} `}</p>
)}
</header>
<textarea
value={message}
onChange={onChangeDraftingMessage}
data-testid="listing-new-message"
id="new-message"
rows="4"
cols="70"
className="crayons-textfield mb-0"
placeholder="Enter your message here..."
/>
<button type="submit" value="Submit" className="submit-button cta">
SEND
</button>
<p>
{isCurrentUserOnListing ? (
<em data-testid="personal-message-about-interactions" id="personal-message-about-interactions">
All private interactions
{' '}
<b>must</b>
{' '}
abide by the
{' '}
<a href="/code-of-conduct">code of conduct</a>
</em>
) : (
<em data-testid="generic-message-about-interactions" id="generic-message-about-interactions">
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
className="mb-4 fs-s color-base-60"
>
{isCurrentUserOnListing &&
'Message must be relevant and on-topic with the listing.'}
All private interactions <b>must</b> abide by the{' '}
<a href="/code-of-conduct" className="crayons-link crayons-link--brand">
Code of Conduct
</a>
.
</p>
<div className="flex">
<Button
variant="primary"
className="mr-2"
tagName="button"
type="submit"
>
Send
</Button>
</div>
</form>
);
};

View file

@ -1,7 +1,8 @@
import { h } from 'preact';
import PropTypes from 'prop-types';
import SingleListing from '../singleListing';
import { SingleListing } from '../singleListing/SingleListing';
import MessageModal from './MessageModal';
import { Button } from '@crayons';
const Modal = ({
currentUserId,
@ -16,36 +17,60 @@ const Modal = ({
}) => {
const shouldRenderMessageModal = listing && listing.contact_via_connect;
const Icon = () => (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
className="crayons-icon pointer-events-none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M12 10.586l4.95-4.95 1.414 1.414-4.95 4.95 4.95 4.95-1.414 1.414-4.95-4.95-4.95 4.95-1.414-1.414 4.95-4.95-4.95-4.95L7.05 5.636l4.95 4.95z" />
</svg>
);
// TODO: Why are we not using the crayons modal component and instead recreating it here?
return (
<div className="single-listing-container">
<div
id="single-listing-container__inner"
className="single-listing-container__inner"
onClick={onClick}
role="button"
onKeyPress={onClick}
tabIndex="0"
>
<SingleListing
onAddTag={onAddTag}
onChangeCategory={onChangeCategory}
listing={listing}
currentUserId={currentUserId}
onOpenModal={onOpenModal}
isOpen
<div
role="dialog"
aria-modal="true"
id="single-listing-container__inner"
className="single-listing-container__inner crayons-modal__box"
>
<div className="crayons-modal__box__header flex s:hidden">
<Button
type="button"
id="close-listing-modal"
tagName="button"
contentType="icon"
variant="ghost"
className="ml-auto"
icon={Icon}
onClick={onClick}
aria-label="Close listing"
/>
{shouldRenderMessageModal && (
<MessageModal
onSubmit={onSubmit}
onChangeDraftingMessage={onChangeDraftingMessage}
message={message}
</div>
<div className="crayons-modal__box__body p-0">
<div className="p-3 m:p-6 l:p-8">
<SingleListing
onAddTag={onAddTag}
onChangeCategory={onChangeCategory}
listing={listing}
currentUserId={currentUserId}
onOpenModal={onOpenModal}
isOpen
/>
</div>
{shouldRenderMessageModal && (
<div className="bg-base-10 p-3 m:p-6 l:p-8">
<MessageModal
onSubmit={onSubmit}
onChangeDraftingMessage={onChangeDraftingMessage}
message={message}
listing={listing}
/>
</div>
)}
<a href="/about-listings" className="single-listing-info-link">
About DEV Listings
</a>
<div className="single-listing-container__spacer" />
</div>
</div>
);

View file

@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
const ModalBackground = ({ onClick }) => (
<div
data-testid="listings-modal-background"
className="listings-modal-background"
className="crayons-modal__overlay"
onClick={onClick}
role="presentation"
id="listings-modal-background"

View file

@ -1,11 +1,12 @@
import { h } from 'preact';
import PropTypes from 'prop-types';
import { Button } from '@crayons';
const NextPageButton = ({ onClick }) => (
<div className="listings-load-more-button">
<button onClick={onClick} type="button">
Load More Listings
</button>
<div className="flex justify-center">
<Button variant="secondary" onClick={onClick} type="button">
Load more...
</Button>
</div>
);

View file

@ -3,16 +3,18 @@ import { selectedTagsPropTypes } from '../../common-prop-types';
const SelectedTags = ({ tags, onRemoveTag, onKeyPress }) => {
return (
<section>
<section class="pt-2">
{tags.map((tag) => (
<span className="listing-tag" key={tag.id} id={`selected-tag-${tag}`}>
<a href={`/listings?t=${tag}`} className="tag-name" data-no-instant>
<span className="listing-tag mr-1" key={tag.id} id={`selected-tag-${tag}`}>
<a href={`/listings?t=${tag}`} className="tag-name crayons-tag" data-no-instant>
<span className="crayons-tag__prefix">#</span>
<span role="button" tabIndex="0">
{tag}
</span>
<span
role="button"
tabIndex="0"
className="px-1"
onClick={(e) => onRemoveTag(e, tag)}
onKeyPress={(e) => onKeyPress(e, tag)}
>

View file

@ -4,7 +4,6 @@ import { fetchSearch } from '../utilities/search';
import ModalBackground from './components/ModalBackground';
import Modal from './components/Modal';
import AllListings from './components/AllListings';
import NextPageButton from './components/NextPageButton';
import ListingFilters from './components/ListingFilters';
import {
LISTING_PAGE_SIZE,
@ -289,10 +288,7 @@ export class Listings extends Component {
this.triggerMasonry();
}
return (
<div className="listings__container">
{shouldRenderModal && (
<ModalBackground onClick={this.handleCloseModal} />
)}
<div className="crayons-layout crayons-layout--2-cols">
<ListingFilters
categories={allCategories}
category={category}
@ -311,20 +307,24 @@ export class Listings extends Component {
onChangeCategory={this.selectCategory}
currentUserId={currentUserId}
onOpenModal={this.handleOpenModal}
showNextPageButton={showNextPageButton}
loadNextPage={this.loadNextPage}
/>
{showNextPageButton && <NextPageButton onClick={this.loadNextPage} />}
{shouldRenderModal && (
<Modal
currentUserId={currentUserId}
onAddTag={this.addTag}
onChangeDraftingMessage={this.handleDraftingMessage}
onClick={this.handleCloseModal}
onChangeCategory={this.selectCategory}
onOpenModal={this.handleOpenModal}
onSubmit={this.handleSubmitMessage}
listing={openedListing}
message={message}
/>
<div className="crayons-modal">
<Modal
currentUserId={currentUserId}
onAddTag={this.addTag}
onChangeDraftingMessage={this.handleDraftingMessage}
onClick={this.handleCloseModal}
onChangeCategory={this.selectCategory}
onOpenModal={this.handleOpenModal}
onSubmit={this.handleSubmitMessage}
listing={openedListing}
message={message}
/>
<ModalBackground onClick={this.handleCloseModal} />
</div>
)}
</div>
);

View file

@ -4,7 +4,11 @@ import listingPropTypes from './listingPropTypes';
const LocationText = ({ location }) => {
return location ? (
<a data-testid="single-listing-location" href={`/listings/?q=${location}`}>
<a
data-testid="single-listing-location"
className="crayons-link crayons-link--secondary"
href={`/listings/?q=${location}`}
>
{'・'}
{location}
</a>
@ -23,18 +27,42 @@ LocationText.defaultProps = {
const AuthorInfo = ({ listing, onCategoryClick }) => {
const { category, location, author = {} } = listing;
const { username, name } = author;
const { username, name, profile_image_90 } = author;
return (
<div className="single-listing-author-info">
<footer className="fs-s flex items-center">
<a
href={`/listings/${category}`}
onClick={(e) => onCategoryClick(e, category)}
data-no-instant
href={`/${username}`}
className="crayons-avatar crayons-avatar--l mr-2"
>
{category}
<img
src={profile_image_90}
alt={name}
width="32"
height="32"
className="crayons-avatar__image"
/>
</a>
<LocationText location={location} /><a href={`/${username}`}>{name}</a>
</div>
<div>
<a
href={`/${username}`}
className="crayons-link fw-medium"
>
{name}
</a>
<p className="fs-xs">
<a
href={`/listings/${category}`}
onClick={(e) => onCategoryClick(e, category)}
data-no-instant
className="crayons-link crayons-link--secondary"
>
{category}
</a>
<LocationText location={location} />
</p>
</div>
</footer>
);
};

View file

@ -1,28 +1,24 @@
import PropTypes from 'prop-types';
import { h, Component, createRef } from 'preact';
// eslint-disable-next-line import/no-unresolved
import ThreeDotsIcon from 'images/overflow-horizontal.svg';
import listingPropTypes from './listingPropTypes';
import { Button } from '@crayons';
const MenuButton = ({ onClick }) => (
<button
type="button"
className="dropdown-btn"
aria-label="Toggle dropdown menu"
onClick={onClick}
const Icon = () => (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
className="crayons-icon"
xmlns="http://www.w3.org/2000/svg"
>
<img
src={ThreeDotsIcon}
className="dropdown-icon"
alt="Dropdown menu icon"
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M7 12a2 2 0 11-4 0 2 2 0 014 0zm7 0a2 2 0 11-4 0 2 2 0 014 0zm5 2a2 2 0 100-4 2 2 0 000 4z"
/>
</button>
</svg>
);
MenuButton.propTypes = {
onClick: PropTypes.func.isRequired,
};
class DropdownMenu extends Component {
componentRef = createRef();
@ -68,20 +64,29 @@ class DropdownMenu extends Component {
const reportUrl = `/report-abuse?url=https://dev.to/listings/${category}/${slug}`;
return (
<div className="dropdown-menu" ref={this.componentRef}>
<MenuButton onClick={this.toggleMenu} />
<div className="dropdown">
<div
className={['dropdown-content', isOpen ? 'showing' : ''].join(' ')}
>
{isOwner ? (
<a href={editUrl} className="listing-edit-button">
Edit
</a>
) : (
<a href={reportUrl}>Report Abuse</a>
)}
</div>
<div className="single-listing__dropdown absolute right-0 top-0" ref={this.componentRef}>
<Button
variant="ghost"
contentType="icon"
tagName="button"
aria-label="Toggle dropdown menu"
icon={Icon}
onClick={this.toggleMenu}
/>
<div
className={`crayons-dropdown absolute right-0 top-100 p-1 ${
isOpen ? 'block' : ''
}`}
>
{isOwner ? (
<a href={editUrl} className="crayons-link crayons-link--block">
Edit
</a>
) : (
<a href={reportUrl} className="crayons-link crayons-link--block">
Report Abuse
</a>
)}
</div>
</div>
);

View file

@ -2,27 +2,34 @@ import PropTypes from 'prop-types';
import { h } from 'preact';
import listingPropTypes from './listingPropTypes';
import DropdownMenu from './DropdownMenu';
import TagLinks from './TagLinks';
const Header = ({ listing, currentUserId, onTitleClick }) => {
const Header = ({ listing, currentUserId, onTitleClick, onAddTag }) => {
const { id, user_id: userId, category, slug, title } = listing;
return (
<h3 className="single-listing-header">
<a
href={`/listings/${category}/${slug}`}
data-no-instant
onClick={(e) => onTitleClick(e, listing)}
data-listing-id={id}
>
{title}
</a>
<header className="mb-3">
<h2 className="fs-2xl fw-bold lh-tight mb-1 pr-8">
<a
href={`/listings/${category}/${slug}`}
data-no-instant
className="crayons-link"
onClick={(e) => onTitleClick(e, listing)}
data-listing-id={id}
>
{title}
</a>
</h2>
<TagLinks tags={listing.tags} onClick={onAddTag} />
<DropdownMenu listing={listing} isOwner={currentUserId === userId} />
</h3>
</header>
);
};
Header.propTypes = {
listing: listingPropTypes.isRequired,
onAddTag: PropTypes.func.isRequired,
currentUserId: PropTypes.number,
onTitleClick: PropTypes.func.isRequired,
};

View file

@ -0,0 +1,104 @@
import { h, Component } from 'preact';
import PropTypes from 'prop-types';
import Header from './Header';
import AuthorInfo from './AuthorInfo';
import listingPropTypes from './listingPropTypes';
export class SingleListing extends Component {
listingContent = (listing, currentUserId, onChangeCategory, onOpenModal, onAddTag) => {
return (
<div className="relative">
<Header
listing={listing}
currentUserId={currentUserId}
onTitleClick={onOpenModal}
onAddTag={onAddTag}
/>
<div
className="mb-4"
dangerouslySetInnerHTML={{ __html: listing.processed_html }} // eslint-disable-line react/no-danger
/>
<AuthorInfo listing={listing} onCategoryClick={onChangeCategory} />
</div>
);
};
listingInline = (listing, currentUserId, onChangeCategory, onOpenModal, onAddTag) => {
return (
<div
className="single-listing relative crayons-card"
id={`single-listing-${listing.id}`}
data-testid={`single-listing-${listing.id}`}
>
<div className="listing-content p-4">
{this.listingContent(
listing,
currentUserId,
onChangeCategory,
onOpenModal,
onAddTag
)}
</div>
</div>
);
};
listingModal = (listing, currentUserId, onChangeCategory, onOpenModal, onAddTag) => {
return (
<div
className="single-listing relative"
id={`single-listing-${listing.id}`}
data-testid={`single-listing-${listing.id}`}
>
<div className="listing-content">
{this.listingContent(
listing,
currentUserId,
onChangeCategory,
onOpenModal,
onAddTag,
)}
</div>
</div>
);
};
render() {
const { listing, currentUserId, onChangeCategory, onOpenModal, isOpen, onAddTag } = this.props;
return (
isOpen ?
this.listingModal(
listing,
currentUserId,
onChangeCategory,
onOpenModal,
onAddTag
)
:
this.listingInline(
listing,
currentUserId,
onChangeCategory,
onOpenModal,
onAddTag
)
);
}
}
SingleListing.propTypes = {
listing: listingPropTypes.isRequired,
onOpenModal: PropTypes.func.isRequired,
onChangeCategory: PropTypes.func.isRequired,
isOpen: PropTypes.bool.isRequired,
currentUserId: PropTypes.number,
onAddTag: PropTypes.func.isRequired
};
SingleListing.defaultProps = {
currentUserId: null,
};
SingleListing.displayName = 'SingleListing';

View file

@ -2,15 +2,17 @@ import PropTypes from 'prop-types';
import { h } from 'preact';
const TagLinks = ({ tags, onClick }) => (
<div className="single-listing-tags">
<div className="-ml-1 single-listing__tags">
{tags.length
? tags.map((tag) => {
return (
<a
href={`/listings?t=${tag}`}
onClick={(e) => onClick(e, tag)}
className="crayons-tag"
data-no-instant
>
<span className="crayons-tag__prefix">#</span>
{tag}
</a>
);

View file

@ -1,57 +0,0 @@
import PropTypes from 'prop-types';
import { h } from 'preact';
import Header from './Header';
import TagLinks from './TagLinks';
import AuthorInfo from './AuthorInfo';
import listingPropTypes from './listingPropTypes';
const SingleListing = ({
listing,
currentUserId,
onAddTag,
onChangeCategory,
onOpenModal,
isOpen,
}) => {
const definedClass = isOpen
? 'single-listing single-listing--opened'
: 'single-listing';
return (
<div
className={definedClass}
id={`single-listing-${listing.id}`}
data-testid={`single-listing-${listing.id}`}
>
<div className="listing-content">
<Header
listing={listing}
currentUserId={currentUserId}
onTitleClick={onOpenModal}
/>
<div
className="single-listing-body"
dangerouslySetInnerHTML={{ __html: listing.processed_html }} // eslint-disable-line react/no-danger
/>
<TagLinks tags={listing.tags} onClick={onAddTag} />
<AuthorInfo listing={listing} onCategoryClick={onChangeCategory} />
</div>
</div>
);
};
SingleListing.propTypes = {
listing: listingPropTypes.isRequired,
onAddTag: PropTypes.func.isRequired,
onOpenModal: PropTypes.func.isRequired,
onChangeCategory: PropTypes.func.isRequired,
isOpen: PropTypes.bool.isRequired,
currentUserId: PropTypes.number,
};
SingleListing.defaultProps = {
currentUserId: null,
};
export default SingleListing;

View file

@ -8,6 +8,7 @@ export const MATCH_LISTING = [
'single-listing-container__inner',
'listing-filters',
'listings-modal-background',
'close-listing-modal',
];
export function updateListings(listings) {
@ -45,11 +46,7 @@ function resizeMasonryItem(item) {
window.getComputedStyle(grid).getPropertyValue('grid-row-gap'),
10,
);
const rowHeight = parseInt(
window.getComputedStyle(grid).getPropertyValue('grid-auto-rows'),
10,
);
const rowHeight = 0;
const rowSpan = Math.ceil(
(item.querySelector('.listing-content').getBoundingClientRect().height +
rowGap) /

View file

@ -26,15 +26,22 @@
<meta name="twitter:card" content="summary_large_image">
<% end %>
<main class="home">
<h1 class="visually-hidden-header">Listings</h1>
<section class="listings-container" id="listings-index-container"
data-category="<%= params[:category] %>" data-listings="<%= @listings_json %>"
data-allcategories="<%= categories_for_display.to_json %>"
<% if @displayed_listing %>
data-displayedlisting="<%= @displayed_listing_json %> "
<% end %>>
</section>
</main>
<header class="crayons-layout flex items-center justify-between p-2 l:p-4 l:pb-0">
<h1 class="">Listings</h1>
<div class="flex items-center">
<a href="<%= new_listing_path %>" class="crayons-btn crayons-btn--secondary">Create</a>
<a href="<%= listings_dashboard_path %>" class="crayons-btn crayons-btn--secondary ml-2">Manage</a>
</div>
</header>
<div class="listings-container" id="listings-index-container"
data-category="<%= params[:category] %>" data-listings="<%= @listings_json %>"
data-allcategories="<%= categories_for_display.to_json %>"
<% if @displayed_listing %>
data-displayedlisting="<%= @displayed_listing_json %> "
<% end %>
>
</div>
<%= javascript_packs_with_chunks_tag "listings", defer: true %>

View file

@ -537,25 +537,6 @@ context "when running the specs that were previously in another file" do
}
end
describe "GET /listings" do
it "has page content" do
get "/listings"
expect(response.body).to include("listing-filters")
end
it "has page content for category page" do
get "/listings/saas"
expect(response.body).to include("listing-filters")
end
end
describe "GETS /listings/new" do
it "has page content" do
get "/listings"
expect(response.body).to include("listing-filters")
end
end
describe "POST /listings" do
before do
create_list(:credit, 20, user_id: user.id)