Add dropdown menu to listings (#4184) [deploy]

* Resolves 2826, add dropdown menu to listings

* Fixed dropdown position

* Refactored single listing

* Revert schema.rb to master

* Fixed tests
This commit is contained in:
Zachary Dixon 2020-02-18 17:08:39 -06:00 committed by GitHub
parent 35c2800c89
commit cf2af6c2dc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 445 additions and 285 deletions

View file

@ -62,11 +62,7 @@
position: relative;
input {
font-size: 0.8em;
@include themeable(
color,
theme-color,
$black
);
@include themeable(color, theme-color, $black);
@include themeable(
background,
theme-container-accent-background,
@ -105,7 +101,9 @@
}
&.clear-all {
background: $light-medium-gray;
a { color: $black }
a {
color: $black;
}
}
span {
color: white;
@ -121,24 +119,16 @@
.classifieds-columns {
display: grid;
grid-gap: 1em;
grid-template-columns: repeat(auto-fill, minmax(300px,1fr));
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
grid-auto-rows: 0;
}
form {
margin: 60px auto 0px;
width: 100%;
max-width: 580px;
@include themeable(
background,
theme-container-background,
white
);
@include themeable(background, theme-container-background, white);
box-sizing: border-box;
@include themeable(
border,
theme-border,
1px solid $light-medium-gray
);
@include themeable(border, theme-border, 1px solid $light-medium-gray);
@include themeable(
box-shadow,
theme-container-box-shadow,
@ -155,12 +145,11 @@
@include themeable(
background,
theme-container-accent-background,
#f2f7fc
#f2f7fc
);
padding: 25px 22px;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
}
.classified-form-inner {
padding: 0px 15px 40px;
@ -175,11 +164,7 @@
}
h2 {
font-size: 1.45em;
@include themeable(
color,
theme-color,
#32325d
);
@include themeable(color, theme-color, #32325d);
margin: 0px;
}
.classified-errors {
@ -210,12 +195,8 @@
theme-container-accent-background,
$light-gray
);
@include themeable(
color,
theme-color,
$black
);
&[type="checkbox"] {
@include themeable(color, theme-color, $black);
&[type='checkbox'] {
width: 100px;
}
}
@ -230,33 +211,30 @@
theme-container-accent-background,
$light-gray
);
@include themeable(
color,
theme-color,
$black
);
@include themeable(color, theme-color, $black);
border: 1px solid $light-medium-gray;
}
select {
width: 100%;
font-size:20px;
font-size: 20px;
}
details {
cursor: pointer;
font-size: 0.8em;
padding: 8px 0px;
@include themeable(
color,
theme-secondary-color,
$medium-gray
);
@include themeable(color, theme-secondary-color, $medium-gray);
}
.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%);
background-image: linear-gradient(
141deg,
$dark-purple 0%,
#1f89db 51%,
#2981e5 75%
);
border-radius: 100px;
font-size: 1.4em;
color: white;
@ -283,7 +261,12 @@
right: 0px;
padding: 18px 0px;
background-color: #aecfef;
background-image: linear-gradient(141deg, #aecfef 0%, #ddeeff 51%, #c8ebf1 75%);
background-image: linear-gradient(
141deg,
#aecfef 0%,
#ddeeff 51%,
#c8ebf1 75%
);
color: #32325d;
text-align: center;
z-index: 20;
@ -298,11 +281,7 @@
a {
text-decoration: underline;
display: inline-block;
@include themeable(
color,
theme-anchor-color,
$bold-blue
);
@include themeable(color, theme-anchor-color, $bold-blue);
}
@media screen and (min-width: 580px) {
font-size: 1.1em;
@ -313,27 +292,15 @@
}
p {
font-size: 0.8em;
@include themeable(
color,
theme-secondary-color,
$medium-gray
);
@include themeable(color, theme-secondary-color, $medium-gray);
}
}
.classifieds-load-more-button {
text-align: center;
button {
@include themeable(
color,
theme-color,
$black
);
@include themeable(color, theme-color, $black);
background: transparent;
@include themeable(
border,
theme-border,
1px solid $light-medium-gray
);
@include themeable(border, theme-border, 1px solid $light-medium-gray);
font-size: 17px;
padding: 14px 5px;
margin: 40px auto 70px;
@ -354,26 +321,14 @@
padding: 5px;
font-size: 17px;
border-radius: 3px;
@include themeable(
background,
theme-container-background,
white
);
@include themeable(background, theme-container-background, white);
margin-top: 10px;
@include themeable(
color,
theme-color,
$black
);
@include themeable(color, theme-color, $black);
border: 1px solid $light-medium-gray;
}
.listingform__tagsoptions {
@include themeable(
border,
theme-border,
1px solid $light-medium-gray
);
@include themeable(border, theme-border, 1px solid $light-medium-gray);
border-bottom-left-radius: 3px;
border-bottom-right-radius: 3px;
}
@ -388,7 +343,9 @@
}
}
.listingform__tagsoptionsbottomrow, .listingform__tagsoptionrulesbutton, .listingform__tagrules--inactive {
.listingform__tagsoptionsbottomrow,
.listingform__tagsoptionrulesbutton,
.listingform__tagrules--inactive {
display: none; //not yet fully functional or relevant
}
@ -457,14 +414,13 @@
text-overflow: ellipsis;
font-size: 0.9em;
break-inside: avoid;
@include themeable(
background,
theme-container-background,
white
);
@include themeable(background, theme-container-background, white);
h3 {
display: flex;
justify-content: space-between;
margin: 0px;
padding: 9px 15px;
position: relative;
@include themeable(
background,
theme-container-accent-background,
@ -472,7 +428,56 @@
);
border-bottom: 1px solid $light-medium-gray;
font-size: 16px;
a { @include themeable(color, theme-color, $black); }
a {
@include themeable(color, theme-color, $black);
}
}
.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-classified-listing-body {
padding: 10px 15px;
@ -491,11 +496,7 @@
.single-classified-listing-tags {
padding: 10px 15px;
a {
@include themeable(
color,
theme-secondary-color,
$dark-medium-gray
);
@include themeable(color, theme-secondary-color, $dark-medium-gray);
@include themeable(
background,
theme-container-accent-background,
@ -511,18 +512,10 @@
}
.single-classified-listing-author-info {
font-size: 0.69em;
@include themeable(
color,
theme-secondary-color,
$medium-gray
);
@include themeable(color, theme-secondary-color, $medium-gray);
padding: 0px 15px 4px;
a {
@include themeable(
color,
theme-secondary-color,
$medium-gray
);
@include themeable(color, theme-secondary-color, $medium-gray);
display: inline-block;
&.classified-listing-edit-button {
margin-left: 4px;
@ -537,7 +530,9 @@
right: 0px;
padding-bottom: 12px;
@media screen and (min-width: 950px) {
h3 { font-size: calc(20px + 0.05vw);}
h3 {
font-size: calc(20px + 0.05vw);
}
.single-classified-listing-body {
font-size: calc(18px + 0.05vw);
}
@ -554,17 +549,9 @@
border-radius: 100px;
font-weight: bold;
margin: 40px auto 10px;
@include themeable(
color,
theme-color,
$black
);
@include themeable(color, theme-color, $black);
border: 3px solid white;
@include themeable(
border-color,
theme-color,
$black
);
@include themeable(border-color, theme-color, $black);
@include themeable(
background-color,
theme-container-accent-background,
@ -572,7 +559,6 @@
);
}
form.listings-contact-via-connect {
border-radius: 3px;
z-index: 20;
@ -584,7 +570,9 @@ form.listings-contact-via-connect {
box-shadow: none;
max-width: 100%;
text-align: right;
p {text-align: left}
p {
text-align: left;
}
width: 94%;
max-width: 600px;
textarea#new-message {
@ -611,7 +599,10 @@ form.listings-contact-via-connect {
.classified-listings-modal-background {
z-index: 15;
position: fixed;
left: 0; top: 0; right: 0; bottom: 0;
left: 0;
top: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
user-select: none;
}
@ -635,11 +626,7 @@ form.listings-contact-via-connect {
cursor: pointer;
margin-bottom: 10px;
user-select: none;
@include themeable(
color,
theme-color,
$black
);
@include themeable(color, theme-color, $black);
@include themeable(
background,
theme-container-background,
@ -654,16 +641,8 @@ form.listings-contact-via-connect {
}
&.active {
@include themeable(
background,
theme-secondary-color,
$dark-gray
);
@include themeable(
color,
theme-container-background,
white
);
@include themeable(background, theme-secondary-color, $dark-gray);
@include themeable(color, theme-container-background, white);
}
}
@ -754,7 +733,6 @@ form.listings-contact-via-connect {
}
.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);
@ -765,7 +743,7 @@ form.listings-contact-via-connect {
background: var(--theme-container-background, #fff);
&.draft {
background: var(--theme-container-accent-background, #fffeec)
background: var(--theme-container-accent-background, #fffeec);
}
&.expired {
@ -808,11 +786,7 @@ form.listings-contact-via-connect {
.dashboard-listing-category {
padding: 10px 15px;
a {
@include themeable(
color,
theme-secondary-color,
$dark-medium-gray
);
@include themeable(color, theme-secondary-color, $dark-medium-gray);
@include themeable(
background,
theme-container-accent-background,
@ -868,9 +842,6 @@ form.listings-contact-via-connect {
}
}
}
}
}
}

View file

@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<SingeListing /> should load a single user listing 1`] = `
exports[`<SingleListing /> should load a single user listing 1`] = `
preact-render-spy (1 nodes)
-------
<div
@ -8,7 +8,7 @@ preact-render-spy (1 nodes)
id="single-classified-listing-22"
>
<div class="listing-content">
<h3>
<h3 class="single-classified-listing-header">
<a
href="/listings/misc/illo-iure-quos-perspiciatis-5hk7"
data-no-instant={true}
@ -17,6 +17,25 @@ preact-render-spy (1 nodes)
>
Illo iure quos perspiciatis.
</a>
<div class="dropdown-menu">
<button
type="button"
class="dropdown-btn"
aria-label="Toggle dropdown menu"
onClick={[Function value]}
>
<img
src=""
class="dropdown-icon"
alt="Dropdown menu icon"
/>
</button>
<div class="dropdown">
<div class="dropdown-content ">
<a href="/report-abuse?url=https://dev.to/listings/misc/illo-iure-quos-perspiciatis-5hk7">Report Abuse</a>
</div>
</div>
</div>
</h3>
<div class="single-classified-listing-body">
<p>Eius et ullam. Dolores et qui. Quis <strong>qui</strong> omnis.</p>
@ -49,7 +68,6 @@ preact-render-spy (1 nodes)
<a href="/listings/?q=West Refugio">・West Refugio</a>
<a href="/mrschristiansenyoko">Mrs. Yoko Christiansen</a>
<a href="/report-abuse?url=https://dev.to/listings/misc/illo-iure-quos-perspiciatis-5hk7">・report abuse</a>
</div>
</div>
</div>

View file

@ -1,6 +1,6 @@
import { h } from 'preact';
import { deep, shallow } from 'preact-render-spy';
import { SingleListing } from '../singleListing';
import { deep } from 'preact-render-spy';
import SingleListing from '../singleListing';
const listing = {
id: 22,
@ -21,7 +21,7 @@ const listing = {
},
};
describe('<SingeListing />', () => {
describe('<SingleListing />', () => {
it('should load a single user listing', () => {
const tree = deep(
<SingleListing
@ -43,7 +43,7 @@ describe('<SingeListing />', () => {
});
describe('should load the following elements', () => {
const context = shallow(
const context = deep(
<SingleListing
onAddTag={() => {
return 'onAddTag';
@ -57,57 +57,62 @@ describe('<SingeListing />', () => {
return 'onOpenModal';
}}
isOpen={false}
/>
/>,
);
expect(context.find('.single-classified-listing').exists()).toBeTruthy();
it('for listing title', () => {
expect(
context.find('.listing-content')
context
.find('.single-classified-listing-header')
.at(0)
.childAt(0)
.text()
.childAt(0)
.text(),
).toEqual('Illo iure quos perspiciatis.');
});
it('for listing tags', () => {
expect(
context.find('.single-classified-listing-tags')
context
.find('.single-classified-listing-tags')
.childAt(0)
.text()
.text(),
).toEqual(listing.tag_list[0]);
});
it('for listing category', () => {
expect(
context.find('.single-classified-listing-author-info')
context
.find('.single-classified-listing-author-info')
.childAt(0)
.text()
.text(),
).toEqual(listing.category);
});
it('for listing location', () => {
expect(
context.find('.single-classified-listing-author-info')
context
.find('.single-classified-listing-author-info')
.childAt(1)
.text()
.text(),
).toEqual(`${listing.location}`);
});
it('for listing author', () => {
expect(
context.find('.single-classified-listing-author-info')
context
.find('.single-classified-listing-author-info')
.childAt(3)
.text()
.text(),
).toEqual(listing.author.name);
});
it('for report abuse button', () => {
expect(
context.find('.single-classified-listing-author-info')
.childAt(4)
.text()
).toEqual('・report abuse');
});
});
});
// describe('<AuthorInfo />', () => {
// it('should load the author info of a single listing', () => {
// const tree = deep(<AuthorInfo listing={listing} />);
// expect(tree).toMatchSnapshot();
// });
// });

View file

@ -1,5 +1,5 @@
import { h, Component } from 'preact';
import { SingleListing } from './singleListing';
import SingleListing from './singleListing';
function resizeMasonryItem(item) {
/* Get the grid object, its row-gap, and the size of its implicit rows */
@ -439,7 +439,7 @@ export class Listings extends Component {
{' '}
<b>must</b>
{' '}
abide by the
abide by the
{' '}
<a href="/code-of-conduct">code of conduct</a>
</em>
@ -449,7 +449,7 @@ abide by the
{' '}
<b>must</b>
{' '}
abide by the
abide by the
{' '}
<a href="/code-of-conduct">code of conduct</a>
</em>

View file

@ -1,116 +0,0 @@
import PropTypes from 'prop-types';
import { h } from 'preact';
export const SingleListing = ({
listing,
onAddTag,
currentUserId,
onChangeCategory,
onOpenModal,
isOpen,
}) => {
const tagLinks = listing.tag_list.map(tag => (
<a
href={`/listings?t=${tag}`}
onClick={e => onAddTag(e, tag)}
data-no-instant
>
{tag}
</a>
));
const editButton =
currentUserId === listing.user_id ? (
<a
href={`/listings/${listing.id}/edit`}
className="classified-listing-edit-button"
>
edit
</a>
) : (
<a
href={`/report-abuse?url=https://dev.to/listings/${listing.category}/${listing.slug}`}
>
report abuse
</a>
);
const locationText = listing.location ? (
<a href={`/listings/?q=${listing.location}`}>
{listing.location}
</a>
) : (
''
);
const definedClass = isOpen
? 'single-classified-listing single-classified-listing--opened'
: 'single-classified-listing';
const listingCard = () => {
return (
<div
className={definedClass}
id={`single-classified-listing-${listing.id}`}
>
<div className="listing-content">
<h3>
<a
href={`/listings/${listing.category}/${listing.slug}`}
data-no-instant
onClick={e => onOpenModal(e, listing)}
data-listing-id={listing.id}
>
{listing.title}
</a>
</h3>
<div
className="single-classified-listing-body"
dangerouslySetInnerHTML={{ __html: listing.processed_html }}
/>
<div className="single-classified-listing-tags">{tagLinks}</div>
<div className="single-classified-listing-author-info">
<a
href={`/listings/${listing.category}`}
onClick={e => onChangeCategory(e, listing.category)}
data-no-instant
>
{listing.category}
</a>
{locationText}
<a href={`/${listing.author.username}`}>{listing.author.name}</a>
{editButton}
</div>
</div>
</div>
);
};
return listingCard();
};
SingleListing.propTypes = {
listing: PropTypes.shape({
id: PropTypes.number,
category: PropTypes.string,
contact_via_connect: PropTypes.bool,
location: PropTypes.string,
processed_html: PropTypes.string,
slug: PropTypes.string,
title: PropTypes.string,
user_id: PropTypes.number,
tag_list: PropTypes.arrayOf(PropTypes.string),
author: PropTypes.shape({
name: PropTypes.string.isRequired,
username: PropTypes.string.isRequired,
profile_image_90: PropTypes.string.isRequired,
}),
}).isRequired,
onAddTag: PropTypes.func.isRequired,
onOpenModal: PropTypes.func.isRequired,
onChangeCategory: PropTypes.func.isRequired,
isOpen: PropTypes.bool.isRequired,
currentUserId: PropTypes.number,
};

View file

@ -0,0 +1,52 @@
import PropTypes from 'prop-types';
import { h } from 'preact';
import listingPropTypes from './listingPropTypes';
const LocationText = ({ location }) => {
return location ? (
<a href={`/listings/?q=${location}`}>
{'・'}
{location}
</a>
) : (
''
);
};
LocationText.propTypes = {
location: PropTypes.string,
};
LocationText.defaultProps = {
location: null,
};
const AuthorInfo = ({ listing, onCategoryClick }) => {
const { category, location, author = {} } = listing;
const { username, name } = author;
return (
<div className="single-classified-listing-author-info">
<a
href={`/listings/${category}`}
onClick={e => onCategoryClick(e, category)}
data-no-instant
>
{category}
</a>
<LocationText location={location} />
<a href={`/${username}`}>{name}</a>
</div>
);
};
AuthorInfo.propTypes = {
listing: listingPropTypes.isRequired,
onCategoryClick: PropTypes.func,
};
AuthorInfo.defaultProps = {
onCategoryClick: () => {},
};
export default AuthorInfo;

View file

@ -0,0 +1,91 @@
import PropTypes from 'prop-types';
import { h, Component, createRef } from 'preact';
// eslint-disable-next-line import/no-unresolved
import ThreeDotsIcon from 'images/three-dots.svg';
import listingPropTypes from './listingPropTypes';
const MenuButton = ({ onClick }) => (
<button
type="button"
className="dropdown-btn"
aria-label="Toggle dropdown menu"
onClick={onClick}
>
<img
src={ThreeDotsIcon}
className="dropdown-icon"
alt="Dropdown menu icon"
/>
</button>
);
MenuButton.propTypes = {
onClick: PropTypes.func.isRequired,
};
class DropdownMenu extends Component {
componentRef = createRef();
static propTypes = {
isOwner: PropTypes.bool.isRequired,
listing: listingPropTypes.isRequired,
};
constructor(props) {
super(props);
this.state = {
isOpen: false,
};
}
toggleMenu = () => {
const { isOpen } = this.state;
this.setState({ isOpen: !isOpen }, this.addOrRemoveClickOutsideHandler);
};
addOrRemoveClickOutsideHandler = () => {
const { isOpen } = this.state;
return isOpen
? document.addEventListener('mousedown', this.handleClickOutside)
: document.removeEventListener('mousedown', this.handleClickOutside);
};
handleClickOutside = e => {
if (
this.componentRef.current &&
!this.componentRef.current.contains(e.target)
) {
this.toggleMenu();
}
};
render() {
const { listing, isOwner } = this.props;
const { isOpen } = this.state;
const { id, category, slug } = listing;
const editUrl = `/listings/${id}/edit`;
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="classified-listing-edit-button">
Edit
</a>
) : (
<a href={reportUrl}>Report Abuse</a>
)}
</div>
</div>
</div>
);
}
}
export default DropdownMenu;

View file

@ -0,0 +1,34 @@
import PropTypes from 'prop-types';
import { h } from 'preact';
import listingPropTypes from './listingPropTypes';
import DropdownMenu from './DropdownMenu';
const Header = ({ listing, currentUserId, onTitleClick }) => {
const { id, user_id: userId, category, slug, title } = listing;
return (
<h3 className="single-classified-listing-header">
<a
href={`/listings/${category}/${slug}`}
data-no-instant
onClick={e => onTitleClick(e, listing)}
data-listing-id={id}
>
{title}
</a>
<DropdownMenu listing={listing} isOwner={currentUserId === userId} />
</h3>
);
};
Header.propTypes = {
listing: listingPropTypes.isRequired,
currentUserId: PropTypes.number,
onTitleClick: PropTypes.func.isRequired,
};
Header.defaultProps = {
currentUserId: null,
};
export default Header;

View file

@ -0,0 +1,31 @@
import PropTypes from 'prop-types';
import { h } from 'preact';
const TagLinks = ({ tags, onClick }) => (
<div className="single-classified-listing-tags">
{tags.length
? tags.map(tag => {
return (
<a
href={`/listings?t=${tag}`}
onClick={e => onClick(e, tag)}
data-no-instant
>
{tag}
</a>
);
})
: null}
</div>
);
TagLinks.propTypes = {
tags: PropTypes.arrayOf(PropTypes.string),
onClick: PropTypes.func.isRequired,
};
TagLinks.defaultProps = {
tags: [],
};
export default TagLinks;

View file

@ -0,0 +1,56 @@
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-classified-listing single-classified-listing--opened'
: 'single-classified-listing';
return (
<div
className={definedClass}
id={`single-classified-listing-${listing.id}`}
>
<div className="listing-content">
<Header
listing={listing}
currentUserId={currentUserId}
onTitleClick={onOpenModal}
/>
<div
className="single-classified-listing-body"
dangerouslySetInnerHTML={{ __html: listing.processed_html }} // eslint-disable-line react/no-danger
/>
<TagLinks tags={listing.tag_list} 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

@ -0,0 +1,18 @@
import PropTypes from 'prop-types';
export default PropTypes.shape({
id: PropTypes.number,
category: PropTypes.string,
contact_via_connect: PropTypes.bool,
location: PropTypes.string,
processed_html: PropTypes.string,
slug: PropTypes.string,
title: PropTypes.string,
user_id: PropTypes.number,
tag_list: PropTypes.arrayOf(PropTypes.string),
author: PropTypes.shape({
name: PropTypes.string.isRequired,
username: PropTypes.string.isRequired,
profile_image_90: PropTypes.string.isRequired,
}),
});

View file

@ -1234,4 +1234,4 @@ ActiveRecord::Schema.define(version: 2020_02_13_182938) do
add_foreign_key "users_roles", "users", on_delete: :cascade
add_foreign_key "webhook_endpoints", "oauth_applications"
add_foreign_key "webhook_endpoints", "users"
end
end