From cf2af6c2dc1e70d242c4f1b19ca05478333eea5d Mon Sep 17 00:00:00 2001 From: Zachary Dixon Date: Tue, 18 Feb 2020 17:08:39 -0600 Subject: [PATCH] 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 --- .../stylesheets/classified_listings.scss | 249 ++++++++---------- .../__snapshots__/singleListing.test.jsx.snap | 24 +- .../listings/__tests__/singleListing.test.jsx | 51 ++-- app/javascript/listings/listings.jsx | 6 +- app/javascript/listings/singleListing.jsx | 116 -------- .../listings/singleListing/AuthorInfo.jsx | 52 ++++ .../listings/singleListing/DropdownMenu.jsx | 91 +++++++ .../listings/singleListing/Header.jsx | 34 +++ .../listings/singleListing/TagLinks.jsx | 31 +++ .../listings/singleListing/index.jsx | 56 ++++ .../singleListing/listingPropTypes.js | 18 ++ db/schema.rb | 2 +- 12 files changed, 445 insertions(+), 285 deletions(-) delete mode 100644 app/javascript/listings/singleListing.jsx create mode 100644 app/javascript/listings/singleListing/AuthorInfo.jsx create mode 100644 app/javascript/listings/singleListing/DropdownMenu.jsx create mode 100644 app/javascript/listings/singleListing/Header.jsx create mode 100644 app/javascript/listings/singleListing/TagLinks.jsx create mode 100644 app/javascript/listings/singleListing/index.jsx create mode 100644 app/javascript/listings/singleListing/listingPropTypes.js diff --git a/app/assets/stylesheets/classified_listings.scss b/app/assets/stylesheets/classified_listings.scss index d16afda3b..c5e2ed3b3 100644 --- a/app/assets/stylesheets/classified_listings.scss +++ b/app/assets/stylesheets/classified_listings.scss @@ -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 { } } } - } - - } } diff --git a/app/javascript/listings/__tests__/__snapshots__/singleListing.test.jsx.snap b/app/javascript/listings/__tests__/__snapshots__/singleListing.test.jsx.snap index 9417c7ce3..8ef4ed5c8 100644 --- a/app/javascript/listings/__tests__/__snapshots__/singleListing.test.jsx.snap +++ b/app/javascript/listings/__tests__/__snapshots__/singleListing.test.jsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[` should load a single user listing 1`] = ` +exports[` should load a single user listing 1`] = ` preact-render-spy (1 nodes) -------
-

+

Illo iure quos perspiciatis. +

Eius et ullam. Dolores et qui. Quis qui omnis.

@@ -49,7 +68,6 @@ preact-render-spy (1 nodes) ・West RefugioMrs. Yoko Christiansen - ・report abuse
diff --git a/app/javascript/listings/__tests__/singleListing.test.jsx b/app/javascript/listings/__tests__/singleListing.test.jsx index a21a896b9..7b859abc2 100644 --- a/app/javascript/listings/__tests__/singleListing.test.jsx +++ b/app/javascript/listings/__tests__/singleListing.test.jsx @@ -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('', () => { +describe('', () => { it('should load a single user listing', () => { const tree = deep( ', () => { }); describe('should load the following elements', () => { - const context = shallow( + const context = deep( { return 'onAddTag'; @@ -57,57 +57,62 @@ describe('', () => { 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('', () => { +// it('should load the author info of a single listing', () => { +// const tree = deep(); +// expect(tree).toMatchSnapshot(); +// }); +// }); diff --git a/app/javascript/listings/listings.jsx b/app/javascript/listings/listings.jsx index ba0a942bf..66a11e541 100644 --- a/app/javascript/listings/listings.jsx +++ b/app/javascript/listings/listings.jsx @@ -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 { {' '} must {' '} -abide by the + abide by the {' '} code of conduct @@ -449,7 +449,7 @@ abide by the {' '} must {' '} -abide by the + abide by the {' '} code of conduct diff --git a/app/javascript/listings/singleListing.jsx b/app/javascript/listings/singleListing.jsx deleted file mode 100644 index 15b79b0ba..000000000 --- a/app/javascript/listings/singleListing.jsx +++ /dev/null @@ -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 => ( - onAddTag(e, tag)} - data-no-instant - > - {tag} - - )); - - const editButton = - currentUserId === listing.user_id ? ( - - ・edit - - ) : ( - - ・report abuse - - ); - - const locationText = listing.location ? ( - -・ - {listing.location} - - ) : ( - '' - ); - - const definedClass = isOpen - ? 'single-classified-listing single-classified-listing--opened' - : 'single-classified-listing'; - - const listingCard = () => { - return ( -
- - ); - }; - - 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, -}; diff --git a/app/javascript/listings/singleListing/AuthorInfo.jsx b/app/javascript/listings/singleListing/AuthorInfo.jsx new file mode 100644 index 000000000..b48012327 --- /dev/null +++ b/app/javascript/listings/singleListing/AuthorInfo.jsx @@ -0,0 +1,52 @@ +import PropTypes from 'prop-types'; +import { h } from 'preact'; +import listingPropTypes from './listingPropTypes'; + +const LocationText = ({ location }) => { + return location ? ( + + {'・'} + {location} + + ) : ( + '' + ); +}; + +LocationText.propTypes = { + location: PropTypes.string, +}; + +LocationText.defaultProps = { + location: null, +}; + +const AuthorInfo = ({ listing, onCategoryClick }) => { + const { category, location, author = {} } = listing; + const { username, name } = author; + return ( + + ); +}; + +AuthorInfo.propTypes = { + listing: listingPropTypes.isRequired, + onCategoryClick: PropTypes.func, +}; + +AuthorInfo.defaultProps = { + onCategoryClick: () => {}, +}; + +export default AuthorInfo; diff --git a/app/javascript/listings/singleListing/DropdownMenu.jsx b/app/javascript/listings/singleListing/DropdownMenu.jsx new file mode 100644 index 000000000..f244bf5ad --- /dev/null +++ b/app/javascript/listings/singleListing/DropdownMenu.jsx @@ -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 }) => ( + +); + +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 ( +
+ +
+
+ {isOwner ? ( + + Edit + + ) : ( + Report Abuse + )} +
+
+
+ ); + } +} + +export default DropdownMenu; diff --git a/app/javascript/listings/singleListing/Header.jsx b/app/javascript/listings/singleListing/Header.jsx new file mode 100644 index 000000000..25eb83ba0 --- /dev/null +++ b/app/javascript/listings/singleListing/Header.jsx @@ -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 ( +

+ onTitleClick(e, listing)} + data-listing-id={id} + > + {title} + + + +

+ ); +}; + +Header.propTypes = { + listing: listingPropTypes.isRequired, + currentUserId: PropTypes.number, + onTitleClick: PropTypes.func.isRequired, +}; + +Header.defaultProps = { + currentUserId: null, +}; + +export default Header; diff --git a/app/javascript/listings/singleListing/TagLinks.jsx b/app/javascript/listings/singleListing/TagLinks.jsx new file mode 100644 index 000000000..86c734536 --- /dev/null +++ b/app/javascript/listings/singleListing/TagLinks.jsx @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types'; +import { h } from 'preact'; + +const TagLinks = ({ tags, onClick }) => ( +
+ {tags.length + ? tags.map(tag => { + return ( + onClick(e, tag)} + data-no-instant + > + {tag} + + ); + }) + : null} +
+); + +TagLinks.propTypes = { + tags: PropTypes.arrayOf(PropTypes.string), + onClick: PropTypes.func.isRequired, +}; + +TagLinks.defaultProps = { + tags: [], +}; + +export default TagLinks; diff --git a/app/javascript/listings/singleListing/index.jsx b/app/javascript/listings/singleListing/index.jsx new file mode 100644 index 000000000..5d0c909ba --- /dev/null +++ b/app/javascript/listings/singleListing/index.jsx @@ -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 ( +
+
+
+
+ + +
+
+ ); +}; + +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; diff --git a/app/javascript/listings/singleListing/listingPropTypes.js b/app/javascript/listings/singleListing/listingPropTypes.js new file mode 100644 index 000000000..bbddaef41 --- /dev/null +++ b/app/javascript/listings/singleListing/listingPropTypes.js @@ -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, + }), +}); diff --git a/db/schema.rb b/db/schema.rb index 5840bd949..440307c67 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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 \ No newline at end of file