diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 4f1efb8f8..7aabdb8c4 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -8,9 +8,11 @@ about: Create a report to help us improve **Describe the bug** + **To Reproduce** + @@ -19,9 +21,11 @@ about: Create a report to help us improve **Expected behavior** + **Screenshots** + **Desktop (please complete the following information):** @@ -38,4 +42,5 @@ about: Create a report to help us improve - Version: **Additional context** + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 70db6972f..b21ade046 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -4,13 +4,17 @@ about: Suggest an idea for this project --- **Is your feature request related to a problem? Please describe.** + **Describe the solution you'd like** + **Describe alternatives you've considered** + **Additional context** + diff --git a/app/assets/images/mobile-onboarding-background.png b/app/assets/images/mobile-onboarding-background.png new file mode 100644 index 000000000..564e66005 Binary files /dev/null and b/app/assets/images/mobile-onboarding-background.png differ diff --git a/app/assets/images/onboarding-background-white.png b/app/assets/images/onboarding-background-white.png new file mode 100644 index 000000000..7cc09d02d Binary files /dev/null and b/app/assets/images/onboarding-background-white.png differ diff --git a/app/assets/images/onboarding-background.png b/app/assets/images/onboarding-background.png new file mode 100644 index 000000000..105342c39 Binary files /dev/null and b/app/assets/images/onboarding-background.png differ diff --git a/app/assets/images/purple-dev-logo.png b/app/assets/images/purple-dev-logo.png new file mode 100644 index 000000000..a46367325 Binary files /dev/null and b/app/assets/images/purple-dev-logo.png differ diff --git a/app/assets/javascripts/hello-dev.js b/app/assets/javascripts/hello-dev.js index 6ac359010..486acfc33 100644 --- a/app/assets/javascripts/hello-dev.js +++ b/app/assets/javascripts/hello-dev.js @@ -1,5 +1,5 @@ var bigDev = -' \n \ + ' \n \ \n \ -oooooooo/- .+ooooooooo: +ooo+ oooo/ \n \ +MMMMMMMMMMm+ -NMMMMMMMMMMs +MMMM: /MMMM/ \n \ @@ -19,5 +19,5 @@ var bigDev = console.log( bigDev, "Hey there! Interested in the code behind dev.to? Well you're in luck - we're open source! Come say hi, tell us what you're debugging, or even lend a hand in our repo - https://github.com/thepracticaldev/dev.to/ \n", - "Did you find a bug or vulnerability? Check out our bug bounty info here: https://dev.to/security" + 'Did you find a bug or vulnerability? Check out our bug bounty info here: https://dev.to/security', ); diff --git a/app/assets/javascripts/initializers/initializeAllFollowButts.js b/app/assets/javascripts/initializers/initializeAllFollowButts.js index 6557346b4..0d150cdf9 100644 --- a/app/assets/javascripts/initializers/initializeAllFollowButts.js +++ b/app/assets/javascripts/initializers/initializeAllFollowButts.js @@ -1,6 +1,6 @@ function initializeAllFollowButts() { var followButts = document.getElementsByClassName('follow-action-button'); - for(var i = 0; i < followButts.length; i++) { + for (var i = 0; i < followButts.length; i++) { initializeFollowButt(followButts[i]); } } @@ -8,10 +8,12 @@ function initializeAllFollowButts() { //private function initializeFollowButt(butt) { - var user = userData() - var deviceWidth = (window.innerWidth > 0) ? window.innerWidth : screen.width; + var user = userData(); + var deviceWidth = window.innerWidth > 0 ? window.innerWidth : screen.width; var buttInfo = JSON.parse(butt.dataset.info); - var userStatus = document.getElementsByTagName('body')[0].getAttribute('data-user-status'); + var userStatus = document + .getElementsByTagName('body')[0] + .getAttribute('data-user-status'); if (userStatus === 'logged-out') { addModalEventListener(butt); return; @@ -19,9 +21,8 @@ function initializeFollowButt(butt) { if (buttInfo.className === 'Tag' && user) { handleTagButtAssignment(user, butt, buttInfo); return; - } - else { - if(butt.dataset.fetched === 'fetched') { + } else { + if (butt.dataset.fetched === 'fetched') { return; } fetchButt(butt, buttInfo); @@ -30,27 +31,34 @@ function initializeFollowButt(butt) { function addModalEventListener(butt) { assignState(butt, 'login'); - butt.onclick = function (e) { + butt.onclick = function(e) { e.preventDefault(); showModal('follow-button'); return; - } + }; } function fetchButt(butt, buttInfo) { butt.dataset.fetched = 'fetched'; var dataRequester; if (window.XMLHttpRequest) { - dataRequester = new XMLHttpRequest(); + dataRequester = new XMLHttpRequest(); } else { - dataRequester = new ActiveXObject('Microsoft.XMLHTTP'); + dataRequester = new ActiveXObject('Microsoft.XMLHTTP'); } dataRequester.onreadystatechange = function() { - if (dataRequester.readyState === XMLHttpRequest.DONE && dataRequester.status === 200) { + if ( + dataRequester.readyState === XMLHttpRequest.DONE && + dataRequester.status === 200 + ) { addButtClickHandle(dataRequester.response, butt); } - } - dataRequester.open('GET', '/follows/' + buttInfo.id + '?followable_type=' + buttInfo.className, true); + }; + dataRequester.open( + 'GET', + '/follows/' + buttInfo.id + '?followable_type=' + buttInfo.className, + true, + ); dataRequester.send(); } @@ -61,11 +69,16 @@ function addButtClickHandle(response, butt) { butt.onclick = function(e) { e.preventDefault(); handleOptimisticButtRender(butt); - } + }; } function handleTagButtAssignment(user, butt, buttInfo) { - var buttAssignmentBoolean = JSON.parse(user.followed_tags).map(function (a) { return a.id; }).indexOf(buttInfo.id) !== -1; + var buttAssignmentBoolean = + JSON.parse(user.followed_tags) + .map(function(a) { + return a.id; + }) + .indexOf(buttInfo.id) !== -1; var buttAssignmentBoolText = buttAssignmentBoolean ? 'true' : 'false'; addButtClickHandle(buttAssignmentBoolText, butt); shouldNotFetch = true; @@ -75,17 +88,13 @@ function assignInitialButtResponse(response, butt) { butt.classList.add('showing'); if (response === 'true' || response === 'mutual') { assignState(butt, 'unfollow'); - } - else if (response === 'follow-back') { - assignState(butt, 'follow-back') - } - else if (response === 'false') { + } else if (response === 'follow-back') { + assignState(butt, 'follow-back'); + } else if (response === 'false') { assignState(butt, 'follow'); - } - else if (response === 'self') { + } else if (response === 'self') { assignState(butt, 'self'); - } - else { + } else { assignState(butt, 'login'); } } @@ -98,25 +107,29 @@ function handleOptimisticButtRender(butt) { } else { // Handles actual following of tags/users try { - //lets try grab the event buttons info data attribute user id - var evFabUserId = JSON.parse(butt.dataset.info).id; - var requestVerb = butt.dataset.verb; - //now for all follow action buttons - document.querySelectorAll('.follow-action-button').forEach(function(fab){ - try{ - //lets check they have info data attributes - if( fab.dataset.info ){ - //and attempt to parse those, to grab that buttons info user id - var fabUserId = JSON.parse(fab.dataset.info).id; - //now does that user id match our event buttons user id? - if ( fabUserId && fabUserId === evFabUserId ) { - //yes - time to assign the same state! - assignState(fab, requestVerb); - } - } - }catch (err) {return} - }); - }catch (err) {return} + //lets try grab the event buttons info data attribute user id + var evFabUserId = JSON.parse(butt.dataset.info).id; + var requestVerb = butt.dataset.verb; + //now for all follow action buttons + document.querySelectorAll('.follow-action-button').forEach(function(fab) { + try { + //lets check they have info data attributes + if (fab.dataset.info) { + //and attempt to parse those, to grab that buttons info user id + var fabUserId = JSON.parse(fab.dataset.info).id; + //now does that user id match our event buttons user id? + if (fabUserId && fabUserId === evFabUserId) { + //yes - time to assign the same state! + assignState(fab, requestVerb); + } + } + } catch (err) { + return; + } + }); + } catch (err) { + return; + } handleFollowButtPress(butt); } diff --git a/app/assets/javascripts/initializers/initializeArticleReactions.js b/app/assets/javascripts/initializers/initializeArticleReactions.js index 10eef26f8..79f76fa20 100644 --- a/app/assets/javascripts/initializers/initializeArticleReactions.js +++ b/app/assets/javascripts/initializers/initializeArticleReactions.js @@ -1,75 +1,84 @@ // Set reaction count to correct number function setReactionCount(reactionName, newCount) { - var reactionClassList = document.getElementById("reaction-butt-" + reactionName).classList; - var reactionNumber = document.getElementById("reaction-number-" + reactionName); + var reactionClassList = document.getElementById( + 'reaction-butt-' + reactionName, + ).classList; + var reactionNumber = document.getElementById( + 'reaction-number-' + reactionName, + ); if (newCount > 0) { - reactionClassList.add("activated"); + reactionClassList.add('activated'); reactionNumber.textContent = newCount; - - } - else { - reactionClassList.remove("activated"); - reactionNumber.textContent = "0"; + } else { + reactionClassList.remove('activated'); + reactionNumber.textContent = '0'; } } function showUserReaction(reactionName, animatedClass) { - document.getElementById("reaction-butt-" + reactionName).classList.add("user-activated", animatedClass); + document + .getElementById('reaction-butt-' + reactionName) + .classList.add('user-activated', animatedClass); } function hideUserReaction(reactionName) { - document.getElementById("reaction-butt-" + reactionName).classList.remove("user-activated", "user-animated"); + document + .getElementById('reaction-butt-' + reactionName) + .classList.remove('user-activated', 'user-animated'); } function hasUserReacted(reactionName) { - return document.getElementById("reaction-butt-" + reactionName) - .classList.contains("user-activated"); - + return document + .getElementById('reaction-butt-' + reactionName) + .classList.contains('user-activated'); } function getNumReactions(reactionName) { - var num = document.getElementById("reaction-number-" + reactionName).textContent; - if (num == "") { + var num = document.getElementById('reaction-number-' + reactionName) + .textContent; + if (num == '') { return 0; } return parseInt(num); } function initializeArticleReactions() { - setTimeout(function () { - if (document.getElementById("article-body")) { - var articleId = document.getElementById("article-body").dataset.articleId; - if (document.getElementById("article-reaction-actions")) { - + setTimeout(function() { + if (document.getElementById('article-body')) { + var articleId = document.getElementById('article-body').dataset.articleId; + if (document.getElementById('article-reaction-actions')) { var ajaxReq; var thisButt = this; if (window.XMLHttpRequest) { ajaxReq = new XMLHttpRequest(); } else { - ajaxReq = new ActiveXObject("Microsoft.XMLHTTP"); + ajaxReq = new ActiveXObject('Microsoft.XMLHTTP'); } - ajaxReq.onreadystatechange = function () { + ajaxReq.onreadystatechange = function() { if (ajaxReq.readyState == XMLHttpRequest.DONE) { var json = JSON.parse(ajaxReq.response); - json.article_reaction_counts.forEach(function (reaction) { - setReactionCount(reaction.category, reaction.count) - }) - json.reactions.forEach(function (reaction) { - if (document.getElementById("reaction-butt-" + reaction.category)) { - showUserReaction(reaction.category, "not-user-animated"); + json.article_reaction_counts.forEach(function(reaction) { + setReactionCount(reaction.category, reaction.count); + }); + json.reactions.forEach(function(reaction) { + if ( + document.getElementById('reaction-butt-' + reaction.category) + ) { + showUserReaction(reaction.category, 'not-user-animated'); } - }) - + }); } - } - ajaxReq.open("GET", "/reactions?article_id=" + articleId, true); + }; + ajaxReq.open('GET', '/reactions?article_id=' + articleId, true); ajaxReq.send(); } } - var reactionButts = document.getElementsByClassName("article-reaction-butt") + var reactionButts = document.getElementsByClassName( + 'article-reaction-butt', + ); for (var i = 0; i < reactionButts.length; i++) { - reactionButts[i].onclick = function (e) { - reactToArticle(articleId, this.dataset.category) + reactionButts[i].onclick = function(e) { + reactToArticle(articleId, this.dataset.category); }; } if (document.getElementById('jump-to-comments')) { @@ -81,7 +90,7 @@ function initializeArticleReactions() { }); }; } - }, 3) + }, 3); } function reactToArticle(articleId, reaction) { @@ -92,18 +101,20 @@ function reactToArticle(articleId, reaction) { hideUserReaction(reaction); setReactionCount(reaction, currentNum - 1); } else { - showUserReaction(reaction, "user-animated"); + showUserReaction(reaction, 'user-animated'); setReactionCount(reaction, currentNum + 1); } } - var userStatus = document.getElementsByTagName('body')[0].getAttribute('data-user-status'); + var userStatus = document + .getElementsByTagName('body')[0] + .getAttribute('data-user-status'); sendHapticMessage('medium'); - if (userStatus == "logged-out") { - showModal("react-to-article"); + if (userStatus == 'logged-out') { + showModal('react-to-article'); return; } else { toggleReaction(); - document.getElementById("reaction-butt-" + reaction).disabled = true; + document.getElementById('reaction-butt-' + reaction).disabled = true; } function createFormdata() { @@ -112,26 +123,26 @@ function reactToArticle(articleId, reaction) { * The logic can be seen in sendFetch.js. */ var formData = new FormData(); - formData.append("reactable_type", "Article"); - formData.append("reactable_id", articleId); - formData.append("category", reaction); + formData.append('reactable_type', 'Article'); + formData.append('reactable_id', articleId); + formData.append('category', reaction); return formData; } getCsrfToken() - .then(sendFetch("reaction-creation", createFormdata())) - .then(function (response) { + .then(sendFetch('reaction-creation', createFormdata())) + .then(function(response) { if (response.status === 200) { return response.json().then(() => { - document.getElementById("reaction-butt-" + reaction).disabled = false; + document.getElementById('reaction-butt-' + reaction).disabled = false; }); } else { toggleReaction(); - document.getElementById("reaction-butt-" + reaction).disabled = false; + document.getElementById('reaction-butt-' + reaction).disabled = false; } }) - .catch(function (error) { + .catch(function(error) { toggleReaction(); - document.getElementById("reaction-butt-" + reaction).disabled = false; - }) + document.getElementById('reaction-butt-' + reaction).disabled = false; + }); } diff --git a/app/assets/javascripts/initializers/initializeFooterMod.js b/app/assets/javascripts/initializers/initializeFooterMod.js index 788fc85cb..e64950ec4 100644 --- a/app/assets/javascripts/initializers/initializeFooterMod.js +++ b/app/assets/javascripts/initializers/initializeFooterMod.js @@ -1,7 +1,10 @@ function initializeFooterMod() { - var footerContainer = document.getElementById("footer-container"); + var footerContainer = document.getElementById('footer-container'); if ( - footerContainer && document.getElementById('page-content').className.indexOf('stories-show') > -1 && !document.getElementById('IS_CENTERED_PAGE') + footerContainer && + document.getElementById('page-content').className.indexOf('stories-show') > + -1 && + !document.getElementById('IS_CENTERED_PAGE') ) { document .getElementById('footer-container') diff --git a/app/assets/javascripts/initializers/initializePWAFunctionality.js b/app/assets/javascripts/initializers/initializePWAFunctionality.js index 0bb53cc63..fb0009197 100644 --- a/app/assets/javascripts/initializers/initializePWAFunctionality.js +++ b/app/assets/javascripts/initializers/initializePWAFunctionality.js @@ -15,21 +15,21 @@ function initializePWAFunctionality() { e.preventDefault(); window.location.reload(); }; - var isTouchDevice = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|DEV-Native-ios/i.test(navigator.userAgent); + var isTouchDevice = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|DEV-Native-ios/i.test( + navigator.userAgent, + ); if (!isTouchDevice) { - var domain = window.location.protocol + '//' + window.location.host - var links = document.getElementsByTagName("a"); - for(var i=0, max=links.length; i', () => { const getArticleForm = () => ( - upload images - {' '} -IMAGES + upload images IMAGES {moreConfigBottomButton} diff --git a/app/javascript/article-form/elements/moreConfig.jsx b/app/javascript/article-form/elements/moreConfig.jsx index 19d5abebc..3c07268c0 100644 --- a/app/javascript/article-form/elements/moreConfig.jsx +++ b/app/javascript/article-form/elements/moreConfig.jsx @@ -70,12 +70,8 @@ export default class MoreConfig extends Component { /> - Change meta tag - {' '} - canonical_url - {' '} -if this post was first published elsewhere - (like your own blog) + Change meta tag canonical_url if this post was first + published elsewhere (like your own blog)
diff --git a/app/javascript/article-form/elements/orgSettings.jsx b/app/javascript/article-form/elements/orgSettings.jsx index c73551e0a..cf7862d4c 100644 --- a/app/javascript/article-form/elements/orgSettings.jsx +++ b/app/javascript/article-form/elements/orgSettings.jsx @@ -2,25 +2,36 @@ import { h } from 'preact'; import PropTypes from 'prop-types'; const orgOptions = (organizations, organizationId) => { - const orgs = organizations.map((organization) => { - if(organizationId === organization.id) { - return( - - ) + const orgs = organizations.map(organization => { + if (organizationId === organization.id) { + return ( + + ); } - return ( - - ) - }) - const nullOrgOption = organizationId === null ? : - orgs.unshift(nullOrgOption) // make first option as "None" - return orgs -} + return ; + }); + const nullOrgOption = + organizationId === null ? ( + + ) : ( + + ); + orgs.unshift(nullOrgOption); // make first option as "None" + return orgs; +}; const OrgSettings = ({ organizations, organizationId, onToggle }) => (
Publish under an organization: - {orgOptions(organizations, organizationId)}
diff --git a/app/javascript/chat/actions.js b/app/javascript/chat/actions.js index 6d1fb0a23..915c81537 100644 --- a/app/javascript/chat/actions.js +++ b/app/javascript/chat/actions.js @@ -98,16 +98,19 @@ export function getChannels( ) { successCb(channels, query); } else { - fetch(`/chat_channel_memberships/find_by_chat_channel_id?chat_channel_id=${retrievalID}`, { - Accept: 'application/json', - 'Content-Type': 'application/json', - credentials: 'same-origin', - }) - .then(response => response.json()) - .then(json => { - channels.unshift(json); - successCb(channels, query); - }) + fetch( + `/chat_channel_memberships/find_by_chat_channel_id?chat_channel_id=${retrievalID}`, + { + Accept: 'application/json', + 'Content-Type': 'application/json', + credentials: 'same-origin', + }, + ) + .then(response => response.json()) + .then(json => { + channels.unshift(json); + successCb(channels, query); + }); } }); } diff --git a/app/javascript/chat/article.jsx b/app/javascript/chat/article.jsx index 5ed396b89..b94cd3db5 100644 --- a/app/javascript/chat/article.jsx +++ b/app/javascript/chat/article.jsx @@ -79,11 +79,15 @@ export default class Article extends Component { .catch(this.handleNewReactionFailure); }; - actionButton = (props) => { + actionButton = props => { const types = { - "heart": ["heart-reaction-button", "like", heartImage], - "unicorn": ["unicorn-reaction-button", "unicorn", unicornImage], - "readinglist": ["readinglist-reaction-button", "readinglist", bookmarkImage] + heart: ['heart-reaction-button', 'like', heartImage], + unicorn: ['unicorn-reaction-button', 'unicorn', unicornImage], + readinglist: [ + 'readinglist-reaction-button', + 'readinglist', + bookmarkImage, + ], }; const curType = types[props.reaction]; @@ -94,12 +98,15 @@ export default class Article extends Component { onClick={this.handleReactionClick} data-category={curType[1]} > - {`${curType[1]} + {`${curType[1]} - ) + ); }; - render() { const article = this.props.resource; let heartReactedClass = ''; @@ -161,15 +168,10 @@ export default class Article extends Component { src={article.user.profile_image_90} alt={article.user.username} /> - - {article.user.name} - {' '} - + {article.user.name} {' '} - | - {' '} - {article.readable_publish_date} + | {article.readable_publish_date} @@ -179,9 +181,18 @@ export default class Article extends Component {
- - - + + +
); diff --git a/app/javascript/chat/channelDetails.jsx b/app/javascript/chat/channelDetails.jsx index 981ee8f2b..380daffd3 100644 --- a/app/javascript/chat/channelDetails.jsx +++ b/app/javascript/chat/channelDetails.jsx @@ -144,24 +144,16 @@ class ChannelDetails extends Component { if (this.userInList(channel.channel_users, user)) { invite = ( - is already in - {' '} - {channel.channel_name} + is already in {channel.channel_name} ); } return (
- profile_image - @ - {user.user.username} - {' '} - - - {' '} - {user.title} - - {' '} + profile_image@ + {user.user.username} - {user.title} + {' '} {invite}
); @@ -175,12 +167,7 @@ class ChannelDetails extends Component { rel="noopener noreferrer" data-content={`users/${user.id}`} > - @ - {user.username} - {' '} - - - {' '} - {user.name} + @{user.username} - {user.name} )); @@ -201,8 +188,7 @@ class ChannelDetails extends Component {

Danger Zone

- You have left this channel - {' '} + You have left this channel{' '} 😢😢😢 diff --git a/app/javascript/chat/channels.jsx b/app/javascript/chat/channels.jsx index 625472ded..6fd63bdf5 100644 --- a/app/javascript/chat/channels.jsx +++ b/app/javascript/chat/channels.jsx @@ -18,13 +18,13 @@ const Channels = ({ const isUnopened = new Date(channel.channel_last_message_at) > new Date(lastOpened) && channel.channel_messages_count > 0; - let newMessagesIndicator = isUnopened ? 'new' : 'old'; + let newMessagesIndicator = isUnopened ? 'new' : 'old'; if (incomingVideoCallChannelIds.indexOf(channel.chat_channel_id) > -1) { newMessagesIndicator = 'video'; } const otherClassname = isActive - ? 'chatchanneltab--active' - : 'chatchanneltab--inactive'; + ? 'chatchanneltab--active' + : 'chatchanneltab--inactive'; return ( - ) - }) + ); + }); let topNotice = ''; if ( expanded && @@ -66,11 +70,9 @@ const Channels = ({
👋 - - {' '} + {' '} Welcome to - DEV Connect - ! You may message anyone you mutually follow. + DEV Connect! You may message anyone you mutually follow.
); } @@ -105,7 +107,7 @@ const Channels = ({ {configFooter}

); -} +}; Channels.propTypes = { activeChannelId: PropTypes.number.isRequired, diff --git a/app/javascript/chat/chat.jsx b/app/javascript/chat/chat.jsx index fda5ed570..5ee9b413c 100644 --- a/app/javascript/chat/chat.jsx +++ b/app/javascript/chat/chat.jsx @@ -64,7 +64,7 @@ export default class Chat extends Component { nonChatView: null, inviteChannels: [], soundOn: true, - videoOn: true + videoOn: true, }; } @@ -198,7 +198,10 @@ export default class Chat extends Component { scrolled: false, }); const channel = channels[0]; - this.triggerSwitchChannel(channel.chat_channel_id, channel.channel_modified_slug); + this.triggerSwitchChannel( + channel.chat_channel_id, + channel.channel_modified_slug, + ); } else { this.setState({ channelsLoaded: true }); } @@ -272,13 +275,13 @@ export default class Chat extends Component { receiveNewMessage = message => { const receivedChatChannelId = message.chat_channel_id; - let newMessages = [] + let newMessages = []; if (this.state.messages[receivedChatChannelId]) { newMessages = this.state.messages[receivedChatChannelId].slice(); newMessages.push(message); if (newMessages.length > 150) { newMessages.shift(); - } + } } const newShowAlert = this.state.activeChannelId === receivedChatChannelId @@ -450,17 +453,17 @@ export default class Chat extends Component { }); }; - handleVideoParticipantChange = (participants) => { - this.setState({videoCallParticipants: participants}) - } + handleVideoParticipantChange = participants => { + this.setState({ videoCallParticipants: participants }); + }; toggleVideoSound = () => { - this.setState({soundOn: !this.state.soundOn}) - } + this.setState({ soundOn: !this.state.soundOn }); + }; toggleVideoVideo = () => { - this.setState({videoOn: !this.state.videoOn}) - } + this.setState({ videoOn: !this.state.videoOn }); + }; triggerSwitchChannel = (id, slug) => { this.setState({ @@ -644,23 +647,15 @@ export default class Chat extends Component { return (
- You and - {' '} + You and{' '} {activeChannel.channel_modified_slug} - - {' '} -are - connected because you both follow each other. All interactions - {' '} + {' '} + are connected because you both follow each other. All interactions{' '} must - - {' '} - abide by the - {' '} - code of conduct -. + {' '} + abide by the code of conduct.
); @@ -669,19 +664,11 @@ are return (
- You have joined - {' '} - {activeChannel.channel_name} -! All interactions - {' '} + You have joined {activeChannel.channel_name}! All interactions{' '} must - - {' '} - abide by the - {' '} - code of conduct -. + {' '} + abide by the code of conduct.
); @@ -885,7 +872,7 @@ are : ''; let channelHeader =
 
; const currentChannel = this.state.activeChannel; - let channelConfigImage = '' + let channelConfigImage = ''; if (currentChannel) { let channelHeaderInner = ''; if (currentChannel.channel_type === 'direct') { @@ -898,7 +885,13 @@ are {currentChannel.channel_modified_slug} ); - channelConfigImage = ; + channelConfigImage = ( + + ); } else { channelHeaderInner = ( ); - channelConfigImage = ; + channelConfigImage = ( + + ); } - if (this.state.activeContent[this.state.activeChannelId] && this.state.activeContent[this.state.activeChannelId].type_of) { + if ( + this.state.activeContent[this.state.activeChannelId] && + this.state.activeContent[this.state.activeChannelId].type_of + ) { channelConfigImage = ''; } channelHeader = (
- {channelHeaderInner} - {' '} - {channelConfigImage} + {channelHeaderInner} {channelConfigImage}
); } @@ -946,8 +946,7 @@ are className="activechatchannel__incomingcall" onClick={this.answerVideoCall} > - 👋 Incoming Video Call - {' '} + 👋 Incoming Video Call{' '} ); } diff --git a/app/javascript/chat/githubRepo.jsx b/app/javascript/chat/githubRepo.jsx index da1fbd735..0bb212e9a 100644 --- a/app/javascript/chat/githubRepo.jsx +++ b/app/javascript/chat/githubRepo.jsx @@ -20,16 +20,12 @@ export default class GithubRepo extends Component { componentDidMount() { if (this.state.token) { getJSONContents( - `https://api.github.com/repos/${ - this.props.resource.args - }/contents?access_token=${this.state.token}`, + `https://api.github.com/repos/${this.props.resource.args}/contents?access_token=${this.state.token}`, this.loadContent, this.loadFailure, ); getJSONContents( - `https://api.github.com/repos/${ - this.props.resource.args - }/readme?access_token=${this.state.token}`, + `https://api.github.com/repos/${this.props.resource.args}/readme?access_token=${this.state.token}`, this.loadContent, this.loadFailure, ); @@ -91,9 +87,7 @@ export default class GithubRepo extends Component {
Authentication required
-

- This feature is in internal alpha testing mode. -

+

This feature is in internal alpha testing mode.

); } @@ -115,9 +109,7 @@ export default class GithubRepo extends Component { data-path={item.path} onClick={this.handleItemClick} > - 📁 - {' '} - {item.name} + 📁 {item.name}
)); diff --git a/app/javascript/chat/userDetails.jsx b/app/javascript/chat/userDetails.jsx index eef79fd92..543101d55 100644 --- a/app/javascript/chat/userDetails.jsx +++ b/app/javascript/chat/userDetails.jsx @@ -13,7 +13,7 @@ function blockChat(activeChannelId) { export default class UserDetails extends Component { render() { - const { user} = this.props; + const { user } = this.props; const channelId = this.props.activeChannelId; const channel = this.props.activeChannel || {}; const socialIcons = []; @@ -65,24 +65,26 @@ export default class UserDetails extends Component { } let blockButton = ''; if (channel.channel_type === 'direct' && window.currentUser.id != user.id) { - blockButton = + blockButton = ( + + ); } return (
diff --git a/app/javascript/chat/video.jsx b/app/javascript/chat/video.jsx index 661e1356d..0b093ca49 100644 --- a/app/javascript/chat/video.jsx +++ b/app/javascript/chat/video.jsx @@ -8,11 +8,11 @@ export default class Video extends Component { let leftPx = 40; let topPx = 70; if (window.innerWidth > 1500) { - leftPx = window.innerWidth/5 + 200; - topPx = window.innerHeight/10; + leftPx = window.innerWidth / 5 + 200; + topPx = window.innerHeight / 10; } else if (window.innerWidth > 641) { - leftPx = window.innerWidth/6; - topPx = window.innerHeight/10; + leftPx = window.innerWidth / 6; + topPx = window.innerHeight / 10; } this.state = { leftPx, @@ -55,26 +55,29 @@ export default class Video extends Component { 'videolocalscreen', ); localMediaContainer.appendChild(track.attach()); - }); - const roomParticipants = []; - room.participants.forEach(participant => { - component.triggerRemoteJoin(participant); - roomParticipants.push(participant); - }); - component.setState({ participants: roomParticipants }); - room.on('participantConnected', function(participant) { + }); + const roomParticipants = []; + room.participants.forEach(participant => { + component.triggerRemoteJoin(participant); + roomParticipants.push(participant); + }); + component.setState({ participants: roomParticipants }); + room.on('participantConnected', function(participant) { component.props.onParticipantChange(room.participants); component.triggerRemoteJoin(participant); room.participants.forEach(p => { roomParticipants.push(p); }); component.setState({ participants: roomParticipants }); - room.on('participantDisconnected', function() { - component.props.onParticipantChange(room.participants) + room.on('participantDisconnected', function() { + component.props.onParticipantChange(room.participants); }); participant.on('dominantSpeakerChanged', participant => { - console.log('The new dominant speaker in the Room is:', participant); - }) + console.log( + 'The new dominant speaker in the Room is:', + participant, + ); + }); }); }, function(error) { @@ -89,15 +92,17 @@ export default class Video extends Component { participant.on('trackAdded', track => { if (!document.getElementById(`${track.kind}-${track.id}`)) { const trackDiv = document.createElement('div'); - trackDiv.className = `chat__videocalltrackdiv--${track.kind}` - trackDiv.appendChild(track.attach()) + trackDiv.className = `chat__videocalltrackdiv--${track.kind}`; + trackDiv.appendChild(track.attach()); document.getElementById('videoremotescreen').appendChild(trackDiv); - document.getElementById('videoremotescreen').lastChild.id = `${track.kind}-${track.id}` + document.getElementById( + 'videoremotescreen', + ).lastChild.id = `${track.kind}-${track.id}`; } }); participant.on('trackRemoved', track => { if (document.getElementById(track.id)) { - document.getElementById(track.id).outerHTML = '' + document.getElementById(track.id).outerHTML = ''; } }); // participant.on('trackDisabled', track => { @@ -144,7 +149,7 @@ export default class Video extends Component { }; toggleSound = () => { - const { room } = this.state + const { room } = this.state; if (room) { room.localParticipant.audioTracks.forEach(track => { if (track.isEnabled) { @@ -152,13 +157,13 @@ export default class Video extends Component { } else { track.enable(); } - }) + }); } - this.props.onToggleSound() - } + this.props.onToggleSound(); + }; toggleVideo = () => { - const { room } = this.state + const { room } = this.state; if (room) { room.localParticipant.videoTracks.forEach(track => { if (track.isEnabled) { @@ -166,11 +171,10 @@ export default class Video extends Component { } else { track.enable(); } - }) + }); } - this.props.onToggleVideo() - - } + this.props.onToggleVideo(); + }; render() { return ( @@ -183,9 +187,7 @@ export default class Video extends Component { >
- -
); diff --git a/app/javascript/listings/dashboard/listingRow.jsx b/app/javascript/listings/dashboard/listingRow.jsx index 7f33ef7d2..d720a0f58 100644 --- a/app/javascript/listings/dashboard/listingRow.jsx +++ b/app/javascript/listings/dashboard/listingRow.jsx @@ -4,9 +4,7 @@ import { h } from 'preact'; export const ListingRow = ({ listing }) => { const tagLinks = listing.tag_list.map(tag => ( - # - {tag} - {' '} + #{tag}{' '} )); diff --git a/app/javascript/listings/elements/orgSettings.jsx b/app/javascript/listings/elements/orgSettings.jsx index 1e9f16a4a..ba2adbfa8 100644 --- a/app/javascript/listings/elements/orgSettings.jsx +++ b/app/javascript/listings/elements/orgSettings.jsx @@ -3,28 +3,41 @@ import { h } from 'preact'; import PropTypes from 'prop-types'; const orgOptions = (organizations, organizationId) => { - const orgs = organizations.map((organization) => { + const orgs = organizations.map(organization => { if (organizationId === organization.id) { return ( - - ) + + ); } - return ( - - ) - }) - const nullOrgOption = organizationId === null ? : - orgs.unshift(nullOrgOption) // make first option as "None" - return orgs -} + return ; + }); + const nullOrgOption = + organizationId === null ? ( + + ) : ( + + ); + orgs.unshift(nullOrgOption); // make first option as "None" + return orgs; +}; const OrgSettings = ({ organizations, organizationId, onToggle }) => (
- {orgOptions(organizations, organizationId)} -

Posting on behalf of org spends org credits.

+

+ Posting on behalf of org spends org credits. +

); diff --git a/app/javascript/listings/listingDashboard.jsx b/app/javascript/listings/listingDashboard.jsx index 6f2842808..a5cee57b6 100644 --- a/app/javascript/listings/listingDashboard.jsx +++ b/app/javascript/listings/listingDashboard.jsx @@ -69,15 +69,10 @@ export class ListingDashboard extends Component { const listingLength = (selected, userListings, organizationListings) => { return selected === 'user' ? ( -

- Listings Made: - {' '} - {userListings.length} -

+

Listings Made: {userListings.length}

) : (

- Listings Made: - {' '} + Listings Made:{' '} { organizationListings.filter( listing => listing.organization_id === selected, @@ -89,15 +84,10 @@ export class ListingDashboard extends Component { const creditCount = (selected, userCreds, organizations) => { return selected === 'user' ? ( -

- Credits Available: - {' '} - {userCredits} -

+

Credits Available: {userCredits}

) : (

- Credits Available: - {' '} + Credits Available:{' '} {organizations.find(org => org.id === selected).unspent_credits_count}

); diff --git a/app/javascript/listings/listingForm.jsx b/app/javascript/listings/listingForm.jsx index e14f520cb..d02d988b0 100644 --- a/app/javascript/listings/listingForm.jsx +++ b/app/javascript/listings/listingForm.jsx @@ -28,13 +28,13 @@ export default class ListingForm extends Component { categoriesForDetails: this.categoriesForDetails, organizations, organizationId: null, // change this for /edit later - } + }; } handleOrgIdChange = e => { const organizationId = e.target.selectedOptions[0].value; - this.setState({ organizationId }) - } + this.setState({ organizationId }); + }; render() { const { @@ -48,36 +48,52 @@ export default class ListingForm extends Component { organizations, organizationId, } = this.state; - const orgArea = (organizations && organizations.length > 0) ? ( - - ) : ( + const orgArea = + organizations && organizations.length > 0 ? ( + + ) : ( '' ); if (id === null) { - return( + return (
- <BodyMarkdown defaultValue={bodyMarkdown} onChange={linkState(this, 'bodyMarkdown')} /> - <Categories categoriesForSelect={categoriesForSelect} categoriesForDetails={categoriesForDetails} onChange={linkState(this, 'category')} category={category} /> - <Tags defaultValue={tagList} category={category} onInput={linkState(this, 'tagList')} /> + <BodyMarkdown + defaultValue={bodyMarkdown} + onChange={linkState(this, 'bodyMarkdown')} + /> + <Categories + categoriesForSelect={categoriesForSelect} + categoriesForDetails={categoriesForDetails} + onChange={linkState(this, 'category')} + category={category} + /> + <Tags + defaultValue={tagList} + category={category} + onInput={linkState(this, 'tagList')} + /> {orgArea} {/* add contact via connect checkbox later */} </div> - ) + ); } // WIP code for edit - return( + return ( <div> <Title defaultValue={title} onChange={linkState(this, 'title')} /> - <BodyMarkdown defaultValue={bodyMarkdown} onChange={linkState(this, 'bodyMarkdown')} /> + <BodyMarkdown + defaultValue={bodyMarkdown} + onChange={linkState(this, 'bodyMarkdown')} + /> <Tags defaultValue={tagList} onInput={linkState(this, 'tagList')} /> {orgArea} {/* add contact via connect checkbox later */} </div> - ) + ); } } diff --git a/app/javascript/listings/listings.jsx b/app/javascript/listings/listings.jsx index 125f8457d..224e552ed 100644 --- a/app/javascript/listings/listings.jsx +++ b/app/javascript/listings/listings.jsx @@ -369,13 +369,7 @@ export class Listings extends Component { onSubmit={this.handleSubmitMessage} > <p> - <b> - Contact - {' '} - {openedListing.author.name} - {' '} - via DEV Connect - </b> + <b>Contact {openedListing.author.name} via DEV Connect</b> </p> <textarea value={this.state.message} @@ -391,12 +385,7 @@ export class Listings extends Component { <p> <em> Message must be relevant and on-topic with the listing. All - private interactions - {' '} - <b>must</b> - {' '} -abide by the - {' '} + private interactions <b>must</b> abide by the{' '} <a href="/code-of-conduct">code of conduct</a> </em> </p> @@ -425,12 +414,7 @@ abide by the </button> <p> <em> - All private interactions - {' '} - <b>must</b> - {' '} -abide by the - {' '} + All private interactions <b>must</b> abide by the{' '} <a href="/code-of-conduct">code of conduct</a> </em> </p> diff --git a/app/javascript/listings/singleListing.jsx b/app/javascript/listings/singleListing.jsx index 42fc51ab0..cfb98b578 100644 --- a/app/javascript/listings/singleListing.jsx +++ b/app/javascript/listings/singleListing.jsx @@ -53,7 +53,12 @@ export const SingleListing = ({ <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}> + <a + href={`/listings/${listing.category}/${listing.slug}`} + data-no-instant + onClick={e => onOpenModal(e, listing)} + data-listing-id={listing.id} + > {listing.title} </a> </h3> diff --git a/app/javascript/onboarding/Onboarding.jsx b/app/javascript/onboarding/Onboarding.jsx new file mode 100644 index 000000000..a943e3ecd --- /dev/null +++ b/app/javascript/onboarding/Onboarding.jsx @@ -0,0 +1,69 @@ +import 'preact/devtools'; +import { h, Component } from 'preact'; + +import IntroSlide from './components/IntroSlide'; +import PersonalInfoForm from './components/PersonalInfoForm'; +import EmailListTermsConditionsForm from './components/EmailListTermsConditionsForm'; +import ClosingSlide from './components/ClosingSlide'; +import FollowTags from './components/FollowTags'; +import FollowUsers from './components/FollowUsers'; +import BioForm from './components/BioForm'; + +export default class Onboarding extends Component { + constructor(props) { + super(props); + + this.nextSlide = this.nextSlide.bind(this); + this.prevSlide = this.prevSlide.bind(this); + + const slides = [ + IntroSlide, + EmailListTermsConditionsForm, + BioForm, + PersonalInfoForm, + FollowTags, + FollowUsers, + ClosingSlide, + ]; + + const url = new URL(window.location); + const previousLocation = url.searchParams.get('referrer'); + + this.slides = slides.map(SlideComponent => ( + <SlideComponent + next={this.nextSlide} + prev={this.prevSlide} + previousLocation={previousLocation} + /> + )); + + this.state = { + currentSlide: 0, + }; + } + + nextSlide() { + const { currentSlide } = this.state; + const nextSlide = currentSlide + 1; + if (nextSlide < this.slides.length) { + this.setState({ + currentSlide: nextSlide, + }); + } + } + + prevSlide() { + const { currentSlide } = this.state; + const prevSlide = currentSlide - 1; + if (prevSlide >= 0) { + this.setState({ + currentSlide: prevSlide, + }); + } + } + + render() { + const { currentSlide } = this.state; + return <div className="onboarding-body">{this.slides[currentSlide]}</div>; + } +} diff --git a/app/javascript/onboarding/__tests__/Onboarding.test.jsx b/app/javascript/onboarding/__tests__/Onboarding.test.jsx new file mode 100644 index 000000000..fbaff71d0 --- /dev/null +++ b/app/javascript/onboarding/__tests__/Onboarding.test.jsx @@ -0,0 +1,317 @@ +import { h } from 'preact'; +import { deep } from 'preact-render-spy'; +import fetch from 'jest-fetch-mock'; + +import Onboarding from '../Onboarding'; +import BioForm from '../components/BioForm'; +import PersonalInfoForm from '../components/PersonalInfoForm'; +import EmailTermsConditionsForm from '../components/EmailListTermsConditionsForm'; +import FollowTags from '../components/FollowTags'; +import FollowUsers from '../components/FollowUsers'; + +global.fetch = fetch; + +function flushPromises() { + return new Promise(resolve => setImmediate(resolve)); +} + +describe('<Onboarding />', () => { + beforeEach(() => { + fetch.resetMocks(); + }); + + const fakeTagResponse = JSON.stringify([ + { + bg_color_hex: '#000000', + id: 715, + name: 'discuss', + text_color_hex: '#ffffff', + }, + { + bg_color_hex: '#f7df1e', + id: 6, + name: 'javascript', + text_color_hex: '#000000', + }, + { + bg_color_hex: '#2a2566', + id: 630, + name: 'career', + text_color_hex: '#ffffff', + }, + ]); + + const fakeUsersToFollowResponse = JSON.stringify([ + { + id: 1, + name: 'Ben Halpern', + profile_image_url: 'ben.jpg', + }, + { + id: 2, + name: 'Krusty the Clown', + profile_image_url: 'clown.jpg', + }, + { + id: 3, + name: 'dev.to staff', + profile_image_url: 'dev.jpg', + }, + ]); + + const dataUser = JSON.stringify({ + followed_tag_names: ['javascript'], + }); + + describe('IntroSlide', () => { + let onboardingSlides; + beforeEach(() => { + document.body.setAttribute('data-user', null); + onboardingSlides = deep(<Onboarding />); + }); + + test('renders properly', () => { + expect(onboardingSlides).toMatchSnapshot(); + }); + + test('should move to the next slide upon clicking the next button', () => { + onboardingSlides.find('.next-button').simulate('click'); + expect(onboardingSlides.state().currentSlide).toBe(1); + }); + }); + + describe('EmailTermsConditionsForm', () => { + let onboardingSlides; + beforeEach(() => { + document.body.setAttribute('data-user', dataUser); + onboardingSlides = deep(<Onboarding />); + onboardingSlides.setState({ currentSlide: 1 }); + }); + + test('renders properly', () => { + expect(onboardingSlides).toMatchSnapshot(); + }); + + test('should not move if code of conduct is not agreed to', () => { + onboardingSlides.find('.next-button').simulate('click'); + expect(onboardingSlides.state().currentSlide).toBe(1); + }); + + test('should not move if terms and conditions are not met', () => { + const event = { + target: { + value: 'checked_code_of_conduct', + name: 'checked_code_of_conduct', + }, + }; + onboardingSlides + .find('#checked_code_of_conduct') + .simulate('change', event); + onboardingSlides.find('.next-button').simulate('click'); + expect(onboardingSlides.state().currentSlide).toBe(1); + }); + + test('should move to the next slide if code of conduct and terms and conditions are met', async () => { + fetch.once({}); + onboardingSlides.find('#checked_code_of_conduct').simulate('change', { + target: { + value: 'checked_code_of_conduct', + name: 'checked_code_of_conduct', + }, + }); + + onboardingSlides + .find('#checked_terms_and_conditions') + .simulate('change', { + target: { + value: 'checked_terms_and_conditions', + name: 'checked_terms_and_conditions', + }, + }); + + const emailTerms = onboardingSlides.find(<EmailTermsConditionsForm />); + expect(emailTerms.state('checked_code_of_conduct')).toBe(true); + onboardingSlides.find('.next-button').simulate('click'); + await flushPromises(); + expect(onboardingSlides.state().currentSlide).toBe(2); + }); + + it('should move to the previous slide upon clicking the back button', () => { + onboardingSlides.find('.back-button').simulate('click'); + expect(onboardingSlides.state().currentSlide).toBe(0); + }); + }); + + describe('BioForm', () => { + let onboardingSlides; + const meta = document.createElement('meta'); + meta.setAttribute('name', 'csrf-token'); + document.body.appendChild(meta); + + beforeEach(() => { + onboardingSlides = deep(<Onboarding />); + onboardingSlides.setState({ currentSlide: 2 }); + document.body.setAttribute('data-user', dataUser); + }); + + test('renders properly', () => { + expect(onboardingSlides).toMatchSnapshot(); + }); + + it('should move to the previous slide upon clicking the back button', () => { + onboardingSlides.find('.back-button').simulate('click'); + expect(onboardingSlides.state().currentSlide).toBe(1); + }); + + test('forms can be filled and submitted', async () => { + fetch.once({}); + const bioForm = onboardingSlides.find(<BioForm />); + const event = { target: { value: 'my bio', name: 'summary' } }; + onboardingSlides.find('textarea').simulate('change', event); + expect(bioForm.state('summary')).toBe('my bio'); + bioForm.find('.next-button').simulate('click'); + await flushPromises(); + expect(onboardingSlides.state().currentSlide).toBe(3); + }); + }); + + describe('PersonalInformationForm', () => { + let onboardingSlides; + const meta = document.createElement('meta'); + meta.setAttribute('name', 'csrf-token'); + document.body.appendChild(meta); + + beforeEach(() => { + onboardingSlides = deep(<Onboarding />); + onboardingSlides.setState({ currentSlide: 3 }); + document.body.setAttribute('data-user', dataUser); + }); + + test('renders properly', () => { + expect(onboardingSlides).toMatchSnapshot(); + }); + + it('should move to the previous slide upon clicking the back button', () => { + onboardingSlides.find('.back-button').simulate('click'); + expect(onboardingSlides.state().currentSlide).toBe(2); + }); + + test('forms can be filled and submitted', async () => { + fetch.once({}); + const personalInfoForm = onboardingSlides.find(<PersonalInfoForm />); + const locationEvent = { + target: { value: 'my location', name: 'location' }, + }; + onboardingSlides.find('#location').simulate('change', locationEvent); + + const titleEvent = { + target: { value: 'my title', name: 'employment_title' }, + }; + onboardingSlides.find('#employment_title').simulate('change', titleEvent); + + const employerEvent = { + target: { value: 'my employer name', name: 'employer_name' }, + }; + onboardingSlides.find('#employer_name').simulate('change', employerEvent); + + expect(personalInfoForm.state('location')).toBe('my location'); + expect(personalInfoForm.state('employment_title')).toBe('my title'); + expect(personalInfoForm.state('employer_name')).toBe('my employer name'); + + personalInfoForm.find('.next-button').simulate('click'); + fetch.once(fakeTagResponse); + await flushPromises(); + expect(onboardingSlides.state().currentSlide).toBe(4); + }); + }); + + describe('FollowTags', () => { + let onboardingSlides; + beforeEach(async () => { + document.body.setAttribute('data-user', dataUser); + onboardingSlides = deep(<Onboarding />); + fetch.once(fakeTagResponse); + onboardingSlides.setState({ currentSlide: 4 }); + await flushPromises(); + }); + + test('renders properly', () => { + expect(onboardingSlides).toMatchSnapshot(); + }); + + test('it should render three tags', async () => { + expect(onboardingSlides.find('.tag').length).toBe(3); + }); + + test('adding a tag and submitting works', async () => { + fetch.once({}); + const followTags = onboardingSlides.find(<FollowTags />); + onboardingSlides + .find('.tag') + .first() + .simulate('click'); + expect(followTags.state('selectedTags').length).toBe(1); + onboardingSlides.find('.next-button').simulate('click'); + fetch.once(fakeUsersToFollowResponse); + await flushPromises(); + expect(onboardingSlides.state().currentSlide).toBe(5); + }); + + it('should move to the previous slide upon clicking the back button', () => { + onboardingSlides.find('.back-button').simulate('click'); + expect(onboardingSlides.state().currentSlide).toBe(3); + }); + }); + + describe('FollowUsers', () => { + let onboardingSlides; + beforeEach(async () => { + document.body.setAttribute('data-user', dataUser); + onboardingSlides = deep(<Onboarding />); + fetch.once(fakeUsersToFollowResponse); + onboardingSlides.setState({ currentSlide: 5 }); + await flushPromises(); + }); + + test('renders properly', () => { + expect(onboardingSlides).toMatchSnapshot(); + }); + + test('it should render three users', async () => { + expect(onboardingSlides.find('.user').length).toBe(3); + }); + + test('adding a user and submitting works', async () => { + fetch.once({}); + const followUsers = onboardingSlides.find(<FollowUsers />); + onboardingSlides + .find('.user') + .first() + .simulate('click'); + expect(followUsers.state('selectedUsers').length).toBe(2); + onboardingSlides.find('.next-button').simulate('click'); + await flushPromises(); + expect(onboardingSlides.state().currentSlide).toBe(6); + }); + + it('should move to the previous slide upon clicking the back button', async () => { + fetch.once(fakeTagResponse); + onboardingSlides.find('.back-button').simulate('click'); + await flushPromises(); + expect(onboardingSlides.state().currentSlide).toBe(4); + }); + }); + + describe('CloseSlide', () => { + let onboardingSlides; + beforeEach(() => { + document.body.setAttribute('data-user', null); + onboardingSlides = deep(<Onboarding />); + onboardingSlides.setState({ currentSlide: 6 }); + }); + + test('renders properly', () => { + expect(onboardingSlides).toMatchSnapshot(); + }); + }); +}); diff --git a/app/javascript/onboarding/__tests__/__snapshots__/Onboarding.test.jsx.snap b/app/javascript/onboarding/__tests__/__snapshots__/Onboarding.test.jsx.snap new file mode 100644 index 000000000..071ea0ba7 --- /dev/null +++ b/app/javascript/onboarding/__tests__/__snapshots__/Onboarding.test.jsx.snap @@ -0,0 +1,431 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`<Onboarding /> BioForm renders properly 1`] = ` +preact-render-spy (1 nodes) +------- +<div class="onboarding-body"> + <div class="onboarding-main"> + <div class="onboarding-content about"> + <h2>About You!</h2> + <form> + <label htmlFor="summary"> + Tell the community about yourself! Write a quick bio about what you do, what you're interested in, or anything else! + <textarea + name="summary" + onChange={[Function bound handleChange]} + maxLength="120" + > + </textarea> + </label> + </form> + </div> + <nav class="onboarding-navigation"> + <button + onClick={[Function bound prevSlide]} + class="back-button" + type="button" + > + BACK + </button> + <button + onClick={[Function bound onSubmit]} + class="next-button" + type="button" + > + Continue + </button> + </nav> + </div> +</div> + +`; + +exports[`<Onboarding /> CloseSlide renders properly 1`] = ` +preact-render-spy (1 nodes) +------- +<div class="onboarding-body"> + <div class="onboarding-main"> + <div class="onboarding-content"> + <h1> + You‘re part of the community! + <span + role="img" + aria-label="tada" + > + 🎉 + </span> + </h1> + <h2 style="text-align: center;">What next?</h2> + <div class="onboarding-what-next"> + <a href="/welcome"> + Join the Welcome Thread + <p class="whatnext-emoji"> + <span + role="img" + aria-label="tada" + > + 😊 + </span> + </p> + </a> + <a href="/new"> + Write your first DEV post + <p class="whatnext-emoji"> + <span + role="img" + aria-label="tada" + > + ✍️ + </span> + </p> + </a> + <a href="/top/infinity"> + Read all-time top posts + <p class="whatnext-emoji"> + <span + role="img" + aria-label="tada" + > + 🤓 + </span> + </p> + </a> + <a href="/settings"> + Customize your profile + <p class="whatnext-emoji"> + <span + role="img" + aria-label="tada" + > + 💅 + </span> + </p> + </a> + </div> + </div> + </div> +</div> + +`; + +exports[`<Onboarding /> EmailTermsConditionsForm renders properly 1`] = ` +preact-render-spy (1 nodes) +------- +<div class="onboarding-body"> + <div class="onboarding-main"> + <div class="onboarding-content checkbox-slide"> + <h2>Some things to check off!</h2> + <form> + <label htmlFor="checked_code_of_conduct"> + <input + type="checkbox" + name="checked_code_of_conduct" + id="checked_code_of_conduct" + checked={false} + onChange={[Function bound handleChange]} + /> + You agree to uphold our + <a + href="/code-of-conduct" + data-no-instant={true} + onClick={[Function onClick]} + > + Code of Conduct + </a> + </label> + <label htmlFor="checked_terms_and_conditions"> + <input + type="checkbox" + id="checked_terms_and_conditions" + name="checked_terms_and_conditions" + checked={false} + onChange={[Function bound handleChange]} + /> + You agree to our + <a + href="/terms" + data-no-instant={true} + onClick={[Function onClick]} + > + Terms and Conditions + </a> + </label> + <h3>Email Preferences</h3> + <label htmlFor="email_membership_newsletter"> + <input + type="checkbox" + name="email_membership_newsletter" + checked={true} + onChange={[Function bound handleChange]} + /> + Do you want to receive our weekly newsletter emails? + </label> + <label htmlFor="email_digest_periodic"> + <input + type="checkbox" + name="email_digest_periodic" + checked={true} + onChange={[Function bound handleChange]} + /> + Do you want to receive a periodic digest with some of the top posts from your tags? + </label> + </form> + </div> + <nav class="onboarding-navigation"> + <button + onClick={[Function bound prevSlide]} + class="back-button" + type="button" + > + BACK + </button> + <button + onClick={[Function bound onSubmit]} + class="next-button" + type="button" + > + Continue + </button> + </nav> + </div> +</div> + +`; + +exports[`<Onboarding /> FollowTags renders properly 1`] = ` +preact-render-spy (1 nodes) +------- +<div class="onboarding-body"> + <div class="onboarding-main"> + <div class="onboarding-content"> + <h2>Follow tags to customize your feed</h2> + <div class="scroll"> + <button + type="button" + onClick={[Function onClick]} + style="background-color: #000000; color: #ffffff;" + class="tag" + > + #discuss + <div class="onboarding-tag-follow-indicator"></div> + </button> + <button + type="button" + onClick={[Function onClick]} + style="background-color: #f7df1e; color: #000000;" + class="tag" + > + #javascript + <div class="onboarding-tag-follow-indicator"></div> + </button> + <button + type="button" + onClick={[Function onClick]} + style="background-color: #2a2566; color: #ffffff;" + class="tag" + > + #career + <div class="onboarding-tag-follow-indicator"></div> + </button> + </div> + </div> + <nav class="onboarding-navigation"> + <button + onClick={[Function bound prevSlide]} + class="back-button" + type="button" + > + BACK + </button> + <button + onClick={[Function bound handleComplete]} + class="next-button" + type="button" + > + Continue + </button> + </nav> + </div> +</div> + +`; + +exports[`<Onboarding /> FollowUsers renders properly 1`] = ` +preact-render-spy (1 nodes) +------- +<div class="onboarding-body"> + <div class="onboarding-main"> + <div class="onboarding-content"> + <h2> + Ok, here are some people we picked for you + </h2> + <div class="scroll"> + <div class="select-all-button-wrapper"> + <button + type="button" + onClick={[Function onClick]} + > + Select All ✅ + </button> + </div> + <button + type="button" + style="background-color: #c7ffe8;" + onClick={[Function onClick]} + class="user" + > + <div class="onboarding-user-follow-status">selected</div> + <img + src="ben.jpg" + alt="" + /> + <span>Ben Halpern</span> + <p></p> + </button> + <button + type="button" + style="background-color: #c7ffe8;" + onClick={[Function onClick]} + class="user" + > + <div class="onboarding-user-follow-status">selected</div> + <img + src="clown.jpg" + alt="" + /> + <span>Krusty the Clown</span> + <p></p> + </button> + <button + type="button" + style="background-color: #c7ffe8;" + onClick={[Function onClick]} + class="user" + > + <div class="onboarding-user-follow-status">selected</div> + <img + src="dev.jpg" + alt="" + /> + <span>dev.to staff</span> + <p></p> + </button> + </div> + </div> + <nav class="onboarding-navigation"> + <button + onClick={[Function bound prevSlide]} + class="back-button" + type="button" + > + BACK + </button> + <button + onClick={[Function bound handleComplete]} + class="next-button" + type="button" + > + Continue + </button> + </nav> + </div> +</div> + +`; + +exports[`<Onboarding /> IntroSlide renders properly 1`] = ` +preact-render-spy (1 nodes) +------- +<div class="onboarding-body"> + <div class="onboarding-main"> + <div class="onboarding-content"> + <h1> + <span>Welcome to the </span> + <img + src="/assets/purple-dev-logo.png" + class="sticker-logo" + alt="DEV" + /> + <span>community!</span> + </h1> + <p> + DEV is where programmers share ideas and help each other grow. It’s a global community for contributing and discovering great ideas, having debates, and making friends. + </p> + <p> + A couple quick questions for you before you get started... + </p> + </div> + <nav class="onboarding-navigation"> + <button + onClick={[Function bound onSubmit]} + class="next-button" + type="button" + > + Continue + </button> + </nav> + </div> +</div> + +`; + +exports[`<Onboarding /> PersonalInformationForm renders properly 1`] = ` +preact-render-spy (1 nodes) +------- +<div class="onboarding-body"> + <div class="onboarding-main"> + <div class="onboarding-content about"> + <h2>About You!</h2> + <form> + <label htmlFor="location"> + Where are you located? + <input + type="text" + name="location" + id="location" + onChange={[Function bound handleChange]} + maxLength="60" + /> + </label> + <label htmlFor="employment_title"> + What is your title? + <input + type="text" + name="employment_title" + id="employment_title" + onChange={[Function bound handleChange]} + maxLength="60" + /> + </label> + <label htmlFor="employer_name"> + Where do you work? + <input + type="text" + name="employer_name" + id="employer_name" + onChange={[Function bound handleChange]} + maxLength="60" + /> + </label> + </form> + </div> + <nav class="onboarding-navigation"> + <button + onClick={[Function bound prevSlide]} + class="back-button" + type="button" + > + BACK + </button> + <button + onClick={[Function bound onSubmit]} + class="next-button" + type="button" + > + Continue + </button> + </nav> + </div> +</div> + +`; diff --git a/app/javascript/onboarding/components/BioForm.jsx b/app/javascript/onboarding/components/BioForm.jsx new file mode 100644 index 000000000..9a30f36dc --- /dev/null +++ b/app/javascript/onboarding/components/BioForm.jsx @@ -0,0 +1,88 @@ +import { h, Component } from 'preact'; +import PropTypes from 'prop-types'; + +import Navigation from './Navigation'; +import { getContentOfToken } from '../utilities'; + +class BioForm extends Component { + constructor(props) { + super(props); + + this.handleChange = this.handleChange.bind(this); + this.onSubmit = this.onSubmit.bind(this); + + this.state = { + summary: '', + }; + } + + componentDidMount() { + const csrfToken = getContentOfToken('csrf-token'); + fetch('/onboarding_update', { + method: 'PATCH', + headers: { + 'X-CSRF-Token': csrfToken, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ user: { last_onboarding_page: 'bio form' } }), + credentials: 'same-origin', + }); + } + + onSubmit() { + const csrfToken = getContentOfToken('csrf-token'); + const { summary } = this.state; + fetch('/onboarding_update', { + method: 'PATCH', + headers: { + 'X-CSRF-Token': csrfToken, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ user: { summary } }), + credentials: 'same-origin', + }).then(response => { + if (response.ok) { + const { next } = this.props; + next(); + } + }); + } + + handleChange(e) { + const { value } = e.target; + + this.setState({ + summary: value, + }); + } + + render() { + const { prev } = this.props; + return ( + <div className="onboarding-main"> + <div className="onboarding-content about"> + <h2>About You!</h2> + <form> + <label htmlFor="summary"> + Tell the community about yourself! Write a quick bio about what + you do, what you're interested in, or anything else! + <textarea + name="summary" + onChange={this.handleChange} + maxLength="120" + /> + </label> + </form> + </div> + <Navigation prev={prev} next={this.onSubmit} /> + </div> + ); + } +} + +BioForm.propTypes = { + prev: PropTypes.func.isRequired, + next: PropTypes.string.isRequired, +}; + +export default BioForm; diff --git a/app/javascript/onboarding/components/ClosingSlide.jsx b/app/javascript/onboarding/components/ClosingSlide.jsx new file mode 100644 index 000000000..4b679ba12 --- /dev/null +++ b/app/javascript/onboarding/components/ClosingSlide.jsx @@ -0,0 +1,94 @@ +import { h, Component } from 'preact'; +import PropTypes from 'prop-types'; + +import Navigation from './Navigation'; +import { getContentOfToken } from '../utilities'; + +class ClosingSlide extends Component { + componentDidMount() { + const csrfToken = getContentOfToken('csrf-token'); + fetch('/onboarding_update', { + method: 'PATCH', + headers: { + 'X-CSRF-Token': csrfToken, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ user: { last_onboarding_page: 'closing slide' } }), + credentials: 'same-origin', + }); + } + + render() { + const { previousLocation, prev, next } = this.props; + + const previousLocationListElement = () => { + if (previousLocation !== 'none' && previousLocation !== null) { + return ( + <a className="onboarding-previous-location" href={previousLocation}> + <div>Or go back to the page you were on before you signed up</div> + <code>{previousLocation}</code> + </a> + ); + } + return null; + }; + + return ( + <div className="onboarding-main"> + <div className="onboarding-content"> + <h1> + You‘re part of the community! + <span role="img" aria-label="tada"> + {' '} + 🎉 + </span> + </h1> + <h2 style={{ textAlign: 'center' }}>What next?</h2> + <div className="onboarding-what-next"> + <a href="/welcome"> + Join the Welcome Thread + <p className="whatnext-emoji"> + <span role="img" aria-label="tada"> + 😊 + </span> + </p> + </a> + <a href="/new"> + Write your first DEV post + <p className="whatnext-emoji"> + <span role="img" aria-label="tada"> + ✍️ + </span> + </p> + </a> + <a href="/top/infinity"> + Read all-time top posts + <p className="whatnext-emoji"> + <span role="img" aria-label="tada"> + 🤓 + </span> + </p> + </a> + <a href="/settings"> + Customize your profile + <p className="whatnext-emoji"> + <span role="img" aria-label="tada"> + 💅 + </span> + </p> + </a> + </div> + {previousLocationListElement()} + </div> + </div> + ); + } +} + +ClosingSlide.propTypes = { + prev: PropTypes.func.isRequired, + next: PropTypes.string.isRequired, + previousLocation: PropTypes.string.isRequired, +}; + +export default ClosingSlide; diff --git a/app/javascript/onboarding/components/EmailListTermsConditionsForm.jsx b/app/javascript/onboarding/components/EmailListTermsConditionsForm.jsx new file mode 100644 index 000000000..d5a77825b --- /dev/null +++ b/app/javascript/onboarding/components/EmailListTermsConditionsForm.jsx @@ -0,0 +1,197 @@ +import { h, Component } from 'preact'; +import PropTypes from 'prop-types'; + +import Navigation from './Navigation'; +import { getContentOfToken } from '../utilities'; + +class EmailTermsConditionsForm extends Component { + constructor(props) { + super(props); + + this.handleChange = this.handleChange.bind(this); + this.onSubmit = this.onSubmit.bind(this); + this.checkRequirements = this.checkRequirements.bind(this); + + this.state = { + checked_code_of_conduct: false, + checked_terms_and_conditions: false, + email_membership_newsletter: true, + email_digest_periodic: true, + message: '', + textShowing: null, + }; + } + + componentDidMount() { + const csrfToken = getContentOfToken('csrf-token'); + fetch('/onboarding_update', { + method: 'PATCH', + headers: { + 'X-CSRF-Token': csrfToken, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + user: { last_onboarding_page: 'emails, COC and T&C form' }, + }), + credentials: 'same-origin', + }); + } + + onSubmit() { + if (!this.checkRequirements()) return; + const csrfToken = getContentOfToken('csrf-token'); + + fetch('/onboarding_checkbox_update', { + method: 'PATCH', + headers: { + 'X-CSRF-Token': csrfToken, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ user: this.state }), + credentials: 'same-origin', + }).then(response => { + if (response.ok) { + localStorage.setItem('shouldRedirectToOnboarding', false); + const { next } = this.props; + next(); + } + }); + } + + checkRequirements() { + const { + checked_code_of_conduct, + checked_terms_and_conditions, + } = this.state; + if (!checked_code_of_conduct) { + this.setState({ + message: 'You must agree to our Code of Conduct before continuing!', + }); + return; + } + if (!checked_terms_and_conditions) { + this.setState({ + message: + 'You must agree to our Terms and Conditions before continuing!', + }); + return; + } + return true; + } + + handleChange(event) { + const { name } = event.target; + this.setState(currentState => ({ + [name]: !currentState[name], + })); + } + + handleShowText(event, id) { + event.preventDefault(); + this.setState({ textShowing: document.getElementById(id).innerHTML }); + } + + backToSlide() { + this.setState({ textShowing: null }); + } + + render() { + const { + message, + checked_code_of_conduct, + checked_terms_and_conditions, + email_membership_newsletter, + email_digest_periodic, + textShowing, + } = this.state; + const { prev } = this.props; + if (textShowing) { + return ( + <div className="onboarding-main"> + <div className="onboarding-content checkbox-slide"> + <button onClick={() => this.backToSlide()}>BACK</button> + <div + dangerouslySetInnerHTML={{ __html: textShowing }} + style={{ height: '360px', overflow: 'scroll' }} + /> + </div> + </div> + ); + } + return ( + <div className="onboarding-main"> + <div className="onboarding-content checkbox-slide"> + <h2>Some things to check off!</h2> + {message && <span className="warning-message">{message}</span>} + <form> + <label htmlFor="checked_code_of_conduct"> + <input + type="checkbox" + name="checked_code_of_conduct" + id="checked_code_of_conduct" + checked={checked_code_of_conduct} + onChange={this.handleChange} + /> + You agree to uphold our + {' '} + <a + href="/code-of-conduct" + data-no-instant + onClick={e => this.handleShowText(e, 'coc')} + > + Code of Conduct + </a> + </label> + <label htmlFor="checked_terms_and_conditions"> + <input + type="checkbox" + id="checked_terms_and_conditions" + name="checked_terms_and_conditions" + checked={checked_terms_and_conditions} + onChange={this.handleChange} + /> + You agree to our + {' '} + <a + href="/terms" + data-no-instant + onClick={e => this.handleShowText(e, 'terms')} + > + Terms and Conditions + </a> + </label> + <h3>Email Preferences</h3> + <label htmlFor="email_membership_newsletter"> + <input + type="checkbox" + name="email_membership_newsletter" + checked={email_membership_newsletter} + onChange={this.handleChange} + /> + Do you want to receive our weekly newsletter emails? + </label> + + <label htmlFor="email_digest_periodic"> + <input + type="checkbox" + name="email_digest_periodic" + checked={email_digest_periodic} + onChange={this.handleChange} + /> + Do you want to receive a periodic digest with some of the top + posts from your tags? + </label> + </form> + </div> + <Navigation prev={prev} next={this.onSubmit} /> + </div> + ); + } +} + +EmailTermsConditionsForm.propTypes = { + prev: PropTypes.func.isRequired, + next: PropTypes.string.isRequired, +}; + +export default EmailTermsConditionsForm; diff --git a/app/javascript/onboarding/components/FollowTags.jsx b/app/javascript/onboarding/components/FollowTags.jsx new file mode 100644 index 000000000..ad163b739 --- /dev/null +++ b/app/javascript/onboarding/components/FollowTags.jsx @@ -0,0 +1,123 @@ +import { h, Component } from 'preact'; +import PropTypes from 'prop-types'; + +import Navigation from './Navigation'; +import { getContentOfToken } from '../utilities'; + +class FollowTags extends Component { + constructor(props) { + super(props); + + this.handleClick = this.handleClick.bind(this); + this.handleComplete = this.handleComplete.bind(this); + + this.state = { + allTags: [], + selectedTags: [], + }; + } + + componentDidMount() { + fetch('/api/tags/onboarding') + .then(response => response.json()) + .then(data => { + this.setState({ allTags: data }); + }); + + const csrfToken = getContentOfToken('csrf-token'); + fetch('/onboarding_update', { + method: 'PATCH', + headers: { + 'X-CSRF-Token': csrfToken, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + user: { last_onboarding_page: 'follow tags page' }, + }), + credentials: 'same-origin', + }); + } + + handleClick(tag) { + let { selectedTags } = this.state; + if (!selectedTags.includes(tag)) { + this.setState(prevState => ({ + selectedTags: [...prevState.selectedTags, tag], + })); + } else { + selectedTags = [...selectedTags]; + const indexToRemove = selectedTags.indexOf(tag); + selectedTags.splice(indexToRemove, 1); + this.setState({ + selectedTags, + }); + } + } + + handleComplete() { + const csrfToken = getContentOfToken('csrf-token'); + const { selectedTags } = this.state; + + Promise.all( + selectedTags.map(tag => + fetch('/follows', { + method: 'POST', + headers: { + 'X-CSRF-Token': csrfToken, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + followable_type: 'Tag', + followable_id: tag.id, + verb: 'follow', + }), + credentials: 'same-origin', + }), + ), + ).then(_ => { + const { next } = this.props; + next(); + }); + } + + render() { + const { prev } = this.props; + const { selectedTags, allTags } = this.state; + return ( + <div className="onboarding-main"> + <div className="onboarding-content"> + <h2>Follow tags to customize your feed</h2> + <div className="scroll"> + {allTags.map(tag => ( + <button + type="button" + onClick={() => this.handleClick(tag)} + style={{ + backgroundColor: tag.bg_color_hex, + color: tag.text_color_hex, + }} + className={ + selectedTags.includes(tag) ? 'tag tag-selected' : 'tag' + } + > + # + {tag.name} + <div className="onboarding-tag-follow-indicator"> + {selectedTags.includes(tag) ? '✓ following ' : ''} + </div> + </button> + ))} + </div> + </div> + <Navigation prev={prev} next={this.handleComplete} /> + </div> + ); + } +} + +FollowTags.propTypes = { + prev: PropTypes.func.isRequired, + next: PropTypes.string.isRequired, +}; + +export default FollowTags; diff --git a/app/javascript/onboarding/components/FollowUsers.jsx b/app/javascript/onboarding/components/FollowUsers.jsx new file mode 100644 index 000000000..e76b9608c --- /dev/null +++ b/app/javascript/onboarding/components/FollowUsers.jsx @@ -0,0 +1,143 @@ +import { h, Component } from 'preact'; +import PropTypes from 'prop-types'; + +import { userInfo } from 'os'; +import Navigation from './Navigation'; +import { getContentOfToken } from '../utilities'; + +class FollowUsers extends Component { + constructor(props) { + super(props); + + this.handleClick = this.handleClick.bind(this); + this.handleComplete = this.handleComplete.bind(this); + + this.state = { + users: [], + selectedUsers: [], + }; + } + + componentDidMount() { + fetch('/api/users?state=follow_suggestions', { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + credentials: 'same-origin', + }) + .then(response => response.json()) + .then(data => { + this.setState({ users: data, selectedUsers: data }); + }); + + const csrfToken = getContentOfToken('csrf-token'); + fetch('/onboarding_update', { + method: 'PATCH', + headers: { + 'X-CSRF-Token': csrfToken, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + user: { last_onboarding_page: 'follow users page' }, + }), + credentials: 'same-origin', + }); + } + + handleComplete() { + const csrfToken = getContentOfToken('csrf-token'); + const { selectedUsers } = this.state; + const { next } = this.props; + + fetch('/api/follows', { + method: 'POST', + headers: { + 'X-CSRF-Token': csrfToken, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({users:selectedUsers}), + credentials: 'same-origin', + }) + + next(); + } + + handleSelectAll() { + const { selectedUsers, users } = this.state; + if (selectedUsers.length === users.length) { + this.setState({ + selectedUsers: [], + }); + } else { + this.setState({ + selectedUsers: users, + }); + } + } + + handleClick(user) { + let { selectedUsers } = this.state; + + if (!selectedUsers.includes(user)) { + this.setState(prevState => ({ + selectedUsers: [...prevState.selectedUsers, user], + })); + } else { + selectedUsers = [...selectedUsers]; + const indexToRemove = selectedUsers.indexOf(user); + selectedUsers.splice(indexToRemove, 1); + this.setState({ + selectedUsers, + }); + } + } + + render() { + const { users, selectedUsers } = this.state; + const { prev } = this.props; + return ( + <div className="onboarding-main"> + <div className="onboarding-content"> + <h2>Ok, here are some people we picked for you</h2> + <div className="scroll"> + <div className="select-all-button-wrapper"> + <button type="button" onClick={() => this.handleSelectAll()}> + Select All + {' '} + {selectedUsers.length === users.length ? '✅' : ''} + </button> + </div> + {users.map(user => ( + <button + type="button" + style={{ + backgroundColor: selectedUsers.includes(user) + ? '#c7ffe8' + : 'white', + }} + onClick={() => this.handleClick(user)} + className="user" + > + <div className="onboarding-user-follow-status"> + {selectedUsers.includes(user) ? 'selected' : ''} + </div> + <img src={user.profile_image_url} alt="" /> + <span>{user.name}</span> + <p>{user.summary}</p> + </button> + ))} + </div> + </div> + <Navigation prev={prev} next={this.handleComplete} /> + </div> + ); + } +} + +FollowUsers.propTypes = { + prev: PropTypes.func.isRequired, + next: PropTypes.string.isRequired, +}; + +export default FollowUsers; diff --git a/app/javascript/onboarding/components/IntroSlide.jsx b/app/javascript/onboarding/components/IntroSlide.jsx new file mode 100644 index 000000000..06dfa5bcf --- /dev/null +++ b/app/javascript/onboarding/components/IntroSlide.jsx @@ -0,0 +1,68 @@ +import { h, Component } from 'preact'; +import PropTypes from 'prop-types'; + +import Navigation from './Navigation'; +import { getContentOfToken } from '../utilities'; + +class IntroSlide extends Component { + constructor(props) { + super(props); + + this.onSubmit = this.onSubmit.bind(this); + } + + componentDidMount() { + const csrfToken = getContentOfToken('csrf-token'); + fetch('/onboarding_update', { + method: 'PATCH', + headers: { + 'X-CSRF-Token': csrfToken, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ user: { last_onboarding_page: 'intro slide' } }), + credentials: 'same-origin', + }); + } + + onSubmit() { + const { next } = this.props; + next(); + } + + render() { + const { prev } = this.props; + return ( + <div className="onboarding-main"> + <div className="onboarding-content"> + <h1> + <span>Welcome to the </span> + <img + src="/assets/purple-dev-logo.png" + className="sticker-logo" + alt="DEV" + /> + <span>community!</span> + </h1> + <p> + DEV is where programmers share ideas and help each other grow. It’s + a global community for contributing and discovering great ideas, + having debates, and making friends. + </p> + <p>A couple quick questions for you before you get started...</p> + </div> + <Navigation prev={prev} next={this.onSubmit} hidePrev /> + </div> + ); + } +} + +// const IntroSlide = ({ prev, next }) => ( + +// ); + +IntroSlide.propTypes = { + prev: PropTypes.func.isRequired, + next: PropTypes.string.isRequired, +}; + +export default IntroSlide; diff --git a/app/javascript/onboarding/components/Navigation.jsx b/app/javascript/onboarding/components/Navigation.jsx new file mode 100644 index 000000000..e266adaf9 --- /dev/null +++ b/app/javascript/onboarding/components/Navigation.jsx @@ -0,0 +1,31 @@ +import { h } from 'preact'; +import PropTypes from 'prop-types'; + +const Navigation = ({ next, prev, hideNext, hidePrev }) => ( + <nav className="onboarding-navigation"> + {!hidePrev && ( + <button onClick={prev} className="back-button" type="button"> + BACK + </button> + )} + {!hideNext && ( + <button onClick={next} className="next-button" type="button"> + Continue + </button> + )} + </nav> +); + +Navigation.propTypes = { + prev: PropTypes.func.isRequired, + next: PropTypes.string.isRequired, + hideNext: PropTypes.bool, + hidePrev: PropTypes.bool, +}; + +Navigation.defaultProps = { + hideNext: false, + hidePrev: false, +}; + +export default Navigation; diff --git a/app/javascript/onboarding/components/PersonalInfoForm.jsx b/app/javascript/onboarding/components/PersonalInfoForm.jsx new file mode 100644 index 000000000..56560176e --- /dev/null +++ b/app/javascript/onboarding/components/PersonalInfoForm.jsx @@ -0,0 +1,129 @@ +import { h, Component } from 'preact'; +import PropTypes from 'prop-types'; + +import Navigation from './Navigation'; +import { getContentOfToken } from '../utilities'; + +class PersonalInfoForm extends Component { + constructor(props) { + super(props); + + this.handleChange = this.handleChange.bind(this); + this.onSubmit = this.onSubmit.bind(this); + + this.state = { + location: '', + employment_title: '', + employer_name: '', + last_onboarding_page: 'personal info form', + }; + } + + componentDidMount() { + const csrfToken = getContentOfToken('csrf-token'); + const { last_onboarding_page } = this.state; + fetch('/onboarding_update', { + method: 'PATCH', + headers: { + 'X-CSRF-Token': csrfToken, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ user: { last_onboarding_page } }), + credentials: 'same-origin', + }); + } + + onSubmit() { + const csrfToken = getContentOfToken('csrf-token'); + + const { + last_onboarding_page, + location, + employer_name, + employment_title, + } = this.state; + + fetch('/onboarding_update', { + method: 'PATCH', + headers: { + 'X-CSRF-Token': csrfToken, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + user: { + last_onboarding_page, + location, + employer_name, + employment_title, + }, + }), + credentials: 'same-origin', + }).then(response => { + if (response.ok) { + const { next } = this.props; + next(); + } + }); + } + + handleChange(e) { + const { name, value } = e.target; + + this.setState({ + [name]: value, + }); + } + + render() { + const { prev } = this.props; + return ( + <div className="onboarding-main"> + <div className="onboarding-content about"> + <h2>About You!</h2> + <form> + <label htmlFor="location"> + Where are you located? + <input + type="text" + name="location" + id="location" + onChange={this.handleChange} + maxLength="60" + /> + </label> + + <label htmlFor="employment_title"> + What is your title? + <input + type="text" + name="employment_title" + id="employment_title" + onChange={this.handleChange} + maxLength="60" + /> + </label> + + <label htmlFor="employer_name"> + Where do you work? + <input + type="text" + name="employer_name" + id="employer_name" + onChange={this.handleChange} + maxLength="60" + /> + </label> + </form> + </div> + <Navigation prev={prev} next={this.onSubmit} /> + </div> + ); + } +} + +PersonalInfoForm.propTypes = { + prev: PropTypes.func.isRequired, + next: PropTypes.string.isRequired, +}; + +export default PersonalInfoForm; diff --git a/app/javascript/onboarding/utilities.js b/app/javascript/onboarding/utilities.js new file mode 100644 index 000000000..b0bbae10c --- /dev/null +++ b/app/javascript/onboarding/utilities.js @@ -0,0 +1,8 @@ +export const jsonToForm = data => { + const form = new FormData(); + data.forEach(item => form.append(item.key, item.value)); + return form; +}; + +export const getContentOfToken = token => + document.querySelector(`meta[name='${token}']`).content; diff --git a/app/javascript/packs/Onboarding.jsx b/app/javascript/packs/Onboarding.jsx index 42f16aca8..ace1a5934 100644 --- a/app/javascript/packs/Onboarding.jsx +++ b/app/javascript/packs/Onboarding.jsx @@ -19,14 +19,9 @@ function isUserSignedIn() { } function renderPage() { - import('../src/Onboarding') + import('../onboarding/Onboarding') .then(({ default: Onboarding }) => { - const waitingForOnboarding = setInterval(function() { - if (document.getElementById('main-head-stylesheet')) { - render(<Onboarding />, document.getElementById('top-bar')); - clearInterval(waitingForOnboarding); - } - }, 3); + render(<Onboarding />, document.getElementById('onboarding-container')); }) .catch(error => { // eslint-disable-next-line no-console @@ -41,10 +36,7 @@ document.ready.then( window.csrfToken = csrfToken; getUnopenedChannels(); - - if (isUserSignedIn() && !currentUser.saw_onboarding) { - renderPage(); - } + renderPage(); }) .catch(error => { // eslint-disable-next-line no-console diff --git a/app/javascript/packs/articleForm.jsx b/app/javascript/packs/articleForm.jsx index e8e3bbcff..1bac4ac64 100644 --- a/app/javascript/packs/articleForm.jsx +++ b/app/javascript/packs/articleForm.jsx @@ -28,7 +28,11 @@ function loadForm() { const { article, organizations, version } = root.dataset; render( - <ArticleForm article={article} organizations={organizations} version={version} />, + <ArticleForm + article={article} + organizations={organizations} + version={version} + />, root, root.firstElementChild, ); diff --git a/app/javascript/packs/notificationSubscriptionHandler.js b/app/javascript/packs/notificationSubscriptionHandler.js index 1b65f1be0..3b8a1139f 100644 --- a/app/javascript/packs/notificationSubscriptionHandler.js +++ b/app/javascript/packs/notificationSubscriptionHandler.js @@ -1,21 +1,18 @@ function loadFunctionality() { - if (!document.getElementById( - 'notification-subscriptions-area', - )) { + if (!document.getElementById('notification-subscriptions-area')) { return; } - const {notifiableId} = document.getElementById( + const { notifiableId } = document.getElementById( 'notification-subscriptions-area', ).dataset; - const {notifiableType} = document.getElementById( + const { notifiableType } = document.getElementById( 'notification-subscriptions-area', ).dataset; - - + const userStatus = document .getElementsByTagName('body')[0] .getAttribute('data-user-status'); - + if (userStatus === 'logged-in') { fetch(`/notification_subscriptions/${notifiableType}/${notifiableId}`, { headers: { @@ -27,13 +24,15 @@ function loadFunctionality() { }) .then(response => response.json()) .then(result => { - document.getElementById(`notification-subscription-label_${result.config}`).classList.add('selected'); + document + .getElementById(`notification-subscription-label_${result.config}`) + .classList.add('selected'); // checkbox.checked = result; }); } - + let updateStatus = () => {}; - + if (userStatus === 'logged-out') { updateStatus = () => { // Disabled because showModal() is globally defined in asset pipeline @@ -41,10 +40,12 @@ function loadFunctionality() { showModal('notification-subscription'); }; } else { - updateStatus = (target) => { - const allButtons = document.getElementsByClassName('notification-subscription-label'); - for(let i = 0; i < allButtons.length; i += 1) { - allButtons[i].classList.remove('selected') + updateStatus = target => { + const allButtons = document.getElementsByClassName( + 'notification-subscription-label', + ); + for (let i = 0; i < allButtons.length; i += 1) { + allButtons[i].classList.remove('selected'); } target.classList.add('selected'); fetch(`/notification_subscriptions/${notifiableType}/${notifiableId}`, { @@ -59,17 +60,19 @@ function loadFunctionality() { config: target.dataset.payload, // notifiable params are passed via URL }), - }) + }); }; } - - const subscriptionButtons = document.getElementsByClassName('notification-subscription-label'); - - for(let i = 0; i < subscriptionButtons.length; i += 1) { + + const subscriptionButtons = document.getElementsByClassName( + 'notification-subscription-label', + ); + + for (let i = 0; i < subscriptionButtons.length; i += 1) { subscriptionButtons[i].addEventListener('click', e => { e.preventDefault(); updateStatus(e.target); - if (typeof sendHapticMessage !== "undefined") { + if (typeof sendHapticMessage !== 'undefined') { sendHapticMessage('medium'); } }); @@ -77,7 +80,7 @@ function loadFunctionality() { if (e.key === 'Enter') { updateStatus(e.target); } - }); + }); } } @@ -85,4 +88,4 @@ window.InstantClick.on('change', () => { loadFunctionality(); }); -loadFunctionality(); \ No newline at end of file +loadFunctionality(); diff --git a/app/javascript/packs/onboardingRedirectCheck.jsx b/app/javascript/packs/onboardingRedirectCheck.jsx new file mode 100644 index 000000000..3b9631b6d --- /dev/null +++ b/app/javascript/packs/onboardingRedirectCheck.jsx @@ -0,0 +1,54 @@ +import { getUserDataAndCsrfToken } from '../chat/util'; + +HTMLDocument.prototype.ready = new Promise(resolve => { + if (document.readyState !== 'loading') { + return resolve(); + } + document.addEventListener('DOMContentLoaded', () => resolve()); + return null; +}); + +function redirectableLocation() { + return ( + window.location.pathname !== '/onboarding' && + window.location.pathname !== '/signout_confirm' + ); +} + +function onboardingSkippable(currentUser) { + return ( + currentUser.saw_onboarding && + currentUser.checked_code_of_conduct && + currentUser.checked_terms_and_conditions + ); +} + +document.ready.then( + getUserDataAndCsrfToken() + .then(({ currentUser }) => { + if (redirectableLocation() && !onboardingSkippable(currentUser)) { + window.location = `${window.location.origin}/onboarding?referrer=${window.location}`; + } + }) + .catch(error => { + // eslint-disable-next-line no-console + console.error('Error getting user and CSRF Token', error); + }), +); + +window.InstantClick.on('change', () => { + getUserDataAndCsrfToken() + .then(({ currentUser }) => { + if ( + redirectableLocation() && + localStorage.getItem('shouldRedirectToOnboarding') === null && + !onboardingSkippable(currentUser) + ) { + window.location = `${window.location.origin}/onboarding?referrer=${window.location}`; + } + }) + .catch(error => { + // eslint-disable-next-line no-console + console.error('Error getting user and CSRF Token', error); + }); +}); diff --git a/app/javascript/packs/orgCreditsSelector.js b/app/javascript/packs/orgCreditsSelector.js index 1ec606c7a..0e4dc261c 100644 --- a/app/javascript/packs/orgCreditsSelector.js +++ b/app/javascript/packs/orgCreditsSelector.js @@ -1,15 +1,17 @@ -const orgCreditsSelect = document.getElementById('org-credits-select') -const orgCreditsNumber = document.getElementById('org-credits-number') -const orgCreditsLink = document.getElementById('org-credits-purchase-link') +const orgCreditsSelect = document.getElementById('org-credits-select'); +const orgCreditsNumber = document.getElementById('org-credits-number'); +const orgCreditsLink = document.getElementById('org-credits-purchase-link'); -orgCreditsNumber.innerText = orgCreditsSelect.selectedOptions[0].dataset.credits +orgCreditsNumber.innerText = + orgCreditsSelect.selectedOptions[0].dataset.credits; -const changeOrgCredits = (event) => { - const selectedOrgCreditsCount = event.target.selectedOptions[0].dataset.credits - const selectedOrgId = event.target.selectedOptions[0].value +const changeOrgCredits = event => { + const selectedOrgCreditsCount = + event.target.selectedOptions[0].dataset.credits; + const selectedOrgId = event.target.selectedOptions[0].value; - orgCreditsNumber.innerText = selectedOrgCreditsCount - orgCreditsLink.href = `/credits/purchase?organization_id=${selectedOrgId}` -} + orgCreditsNumber.innerText = selectedOrgCreditsCount; + orgCreditsLink.href = `/credits/purchase?organization_id=${selectedOrgId}`; +}; -orgCreditsSelect.addEventListener('change', changeOrgCredits) +orgCreditsSelect.addEventListener('change', changeOrgCredits); diff --git a/app/javascript/src/Onboarding.jsx b/app/javascript/src/Onboarding.jsx index 7981ee90c..54e7eb328 100644 --- a/app/javascript/src/Onboarding.jsx +++ b/app/javascript/src/Onboarding.jsx @@ -1,4 +1,4 @@ -import { h, render, Component } from 'preact'; +import { h, Component } from 'preact'; import OnboardingWelcome from './components/OnboardingWelcome'; import OnboardingFollowTags from './components/OnboardingFollowTags'; import OnboardingFollowUsers from './components/OnboardingFollowUsers'; @@ -40,7 +40,6 @@ class Onboarding extends Component { followRequestSent: false, articles: [], savedArticles: [], - saveRequestSent: false, profileInfo: {}, }; } @@ -59,6 +58,7 @@ class Onboarding extends Component { document.body.getAttribute('data-user'), ).followed_tag_names; function checkFollowingStatus(followedTags, jsonTags) { + if (!followedTags || !followedTags.length) return jsonTags; const newJSON = jsonTags; jsonTags.map((tag, index) => { if (followedTags.includes(tag.name)) { @@ -127,7 +127,6 @@ class Onboarding extends Component { const formData = getFormDataAndAppend([ { key: 'user', value: JSON.stringify(profileInfo) }, ]); - fetch('/onboarding_update', { method: 'PATCH', headers: { @@ -135,10 +134,6 @@ class Onboarding extends Component { }, body: formData, credentials: 'same-origin', - }).then(response => { - if (response.ok) { - this.setState({ saveRequestSent: true }); - } }); } @@ -361,6 +356,19 @@ class Onboarding extends Component { } } + renderCloseButton() { + const btnClassName = 'close-button'; + return ( + <button + className={btnClassName} + type="button" + onClick={this.closeOnboarding} + > + <img src={cancelSvg} alt="cancel button" /> + </button> + ); + } + renderBackButton() { const { pageNumber } = this.state; if (pageNumber > 1) { @@ -370,9 +378,7 @@ class Onboarding extends Component { type="button" onClick={this.handleBackButton} > - {' '} BACK - {' '} </button> ); } @@ -395,29 +401,24 @@ class Onboarding extends Component { } renderPageIndicators() { + const { pageNumber } = this.state; + const firstIndicatorClassName = + pageNumber === 2 + ? 'pageindicator pageindicator--active' + : 'pageindicator'; + const secondIndicatorClassName = + pageNumber === 3 + ? 'pageindicator pageindicator--active' + : 'pageindicator'; + const thirdIndicatorClassName = + pageNumber === 4 + ? 'pageindicator pageindicator--active' + : 'pageindicator'; return ( <div className="pageindicators"> - <div - className={ - this.state.pageNumber === 2 - ? 'pageindicator pageindicator--active' - : 'pageindicator' - } - /> - <div - className={ - this.state.pageNumber === 3 - ? 'pageindicator pageindicator--active' - : 'pageindicator' - } - /> - <div - className={ - this.state.pageNumber === 4 - ? 'pageindicator pageindicator--active' - : 'pageindicator' - } - /> + <div className={firstIndicatorClassName} /> + <div className={secondIndicatorClassName} /> + <div className={thirdIndicatorClassName} /> </div> ); } @@ -437,46 +438,37 @@ class Onboarding extends Component { render() { const { showOnboarding } = this.state; - if (showOnboarding) { - return ( - <div className="global-modal" style="display:none"> - <div className="global-modal-bg"> - <button className="close-button" onClick={this.closeOnboarding}> - <img src={cancelSvg} alt="cancel button" /> - </button> + if (!showOnboarding) return null; + + return ( + <div className="global-modal" style={{ display: 'none' }}> + <div className="global-modal-bg">{this.renderCloseButton()}</div> + <div className="global-modal-inner"> + <div className="modal-header"> + <div className="triangle-isosceles"> + {this.renderSloanMessage()} + </div> </div> - <div className="global-modal-inner"> - <div className="modal-header"> - <div className="triangle-isosceles"> - {this.renderSloanMessage()} - </div> + <div className="modal-body"> + <div id="sloan-mascot-onboarding-area" className="sloan-bar wiggle"> + <img + src="https://res.cloudinary.com/practicaldev/image/fetch/s--iiubRINO--/c_imagga_scale,f_auto,fl_progressive,q_auto,w_300/https://practicaldev-herokuapp-com.freetls.fastly.net/assets/sloan.png" + className="sloan-img" + alt="Sloan, the sloth mascot" + /> </div> - <div className="modal-body"> - <div - id="sloan-mascot-onboarding-area" - className="sloan-bar wiggle" - > - <img - src="https://res.cloudinary.com/practicaldev/image/fetch/s--iiubRINO--/c_imagga_scale,f_auto,fl_progressive,q_auto,w_300/https://practicaldev-herokuapp-com.freetls.fastly.net/assets/sloan.png" - className="sloan-img" - alt="Sloan, the sloth mascot" - /> - </div> - <div className="body-message">{this.toggleOnboardingSlide()}</div> - </div> - <div className="modal-footer"> - <div className="modal-footer-left">{this.renderBackButton()}</div> - <div className="modal-footer-center"> - {this.renderPageIndicators()} - </div> - <div className="modal-footer-right"> - {this.renderNextButton()} - </div> + <div className="body-message">{this.toggleOnboardingSlide()}</div> + </div> + <div className="modal-footer"> + <div className="modal-footer-left">{this.renderBackButton()}</div> + <div className="modal-footer-center"> + {this.renderPageIndicators()} </div> + <div className="modal-footer-right">{this.renderNextButton()}</div> </div> </div> - ); - } + </div> + ); } } diff --git a/app/javascript/src/__tests__/__snapshots__/Onboarding.test.jsx.snap b/app/javascript/src/__tests__/__snapshots__/Onboarding.test.jsx.snap index bbde3463f..f0457969b 100644 --- a/app/javascript/src/__tests__/__snapshots__/Onboarding.test.jsx.snap +++ b/app/javascript/src/__tests__/__snapshots__/Onboarding.test.jsx.snap @@ -5,11 +5,12 @@ preact-render-spy (1 nodes) ------- <div class="global-modal" - style="display:none" + style="display: none;" > <div class="global-modal-bg"> <button class="close-button" + type="button" onClick={[Function bound closeOnboarding]} > <img @@ -90,7 +91,7 @@ preact-render-spy (1 nodes) type="button" onClick={[Function bound handleBackButton]} > - BACK + BACK </button> </div> <div class="modal-footer-center"> @@ -122,11 +123,12 @@ preact-render-spy (1 nodes) ------- <div class="global-modal" - style="display:none" + style="display: none;" > <div class="global-modal-bg"> <button class="close-button" + type="button" onClick={[Function bound closeOnboarding]} > <img @@ -169,7 +171,7 @@ preact-render-spy (1 nodes) type="button" onClick={[Function bound handleBackButton]} > - BACK + BACK </button> </div> <div class="modal-footer-center"> @@ -199,7 +201,7 @@ preact-render-spy (1 nodes) exports[`<Onboarding /> Final page special next button exists and should reroute user 1`] = ` preact-render-spy (1 nodes) ------- -{undefined} +{null} `; @@ -208,11 +210,12 @@ preact-render-spy (1 nodes) ------- <div class="global-modal" - style="display:none" + style="display: none;" > <div class="global-modal-bg"> <button class="close-button" + type="button" onClick={[Function bound closeOnboarding]} > <img @@ -320,7 +323,7 @@ preact-render-spy (1 nodes) type="button" onClick={[Function bound handleBackButton]} > - BACK + BACK </button> </div> <div class="modal-footer-center"> @@ -352,11 +355,12 @@ preact-render-spy (1 nodes) ------- <div class="global-modal" - style="display:none" + style="display: none;" > <div class="global-modal-bg"> <button class="close-button" + type="button" onClick={[Function bound closeOnboarding]} > <img @@ -437,7 +441,7 @@ preact-render-spy (1 nodes) type="button" onClick={[Function bound handleBackButton]} > - BACK + BACK </button> </div> <div class="modal-footer-center"> @@ -469,11 +473,12 @@ preact-render-spy (1 nodes) ------- <div class="global-modal" - style="display:none" + style="display: none;" > <div class="global-modal-bg"> <button class="close-button" + type="button" onClick={[Function bound closeOnboarding]} > <img @@ -588,7 +593,7 @@ preact-render-spy (1 nodes) type="button" onClick={[Function bound handleBackButton]} > - BACK + BACK </button> </div> <div class="modal-footer-center"> @@ -620,11 +625,12 @@ preact-render-spy (1 nodes) ------- <div class="global-modal" - style="display:none" + style="display: none;" > <div class="global-modal-bg"> <button class="close-button" + type="button" onClick={[Function bound closeOnboarding]} > <img @@ -693,11 +699,12 @@ preact-render-spy (1 nodes) ------- <div class="global-modal" - style="display:none" + style="display: none;" > <div class="global-modal-bg"> <button class="close-button" + type="button" onClick={[Function bound closeOnboarding]} > <img @@ -812,7 +819,7 @@ preact-render-spy (1 nodes) type="button" onClick={[Function bound handleBackButton]} > - BACK + BACK </button> </div> <div class="modal-footer-center"> @@ -844,11 +851,12 @@ preact-render-spy (1 nodes) ------- <div class="global-modal" - style="display:none" + style="display: none;" > <div class="global-modal-bg"> <button class="close-button" + type="button" onClick={[Function bound closeOnboarding]} > <img @@ -956,7 +964,7 @@ preact-render-spy (1 nodes) type="button" onClick={[Function bound handleBackButton]} > - BACK + BACK </button> </div> <div class="modal-footer-center"> @@ -988,11 +996,12 @@ preact-render-spy (1 nodes) ------- <div class="global-modal" - style="display:none" + style="display: none;" > <div class="global-modal-bg"> <button class="close-button" + type="button" onClick={[Function bound closeOnboarding]} > <img @@ -1073,7 +1082,7 @@ preact-render-spy (1 nodes) type="button" onClick={[Function bound handleBackButton]} > - BACK + BACK </button> </div> <div class="modal-footer-center"> @@ -1105,11 +1114,12 @@ preact-render-spy (1 nodes) ------- <div class="global-modal" - style="display:none" + style="display: none;" > <div class="global-modal-bg"> <button class="close-button" + type="button" onClick={[Function bound closeOnboarding]} > <img @@ -1217,7 +1227,7 @@ preact-render-spy (1 nodes) type="button" onClick={[Function bound handleBackButton]} > - BACK + BACK </button> </div> <div class="modal-footer-center"> @@ -1249,11 +1259,12 @@ preact-render-spy (1 nodes) ------- <div class="global-modal" - style="display:none" + style="display: none;" > <div class="global-modal-bg"> <button class="close-button" + type="button" onClick={[Function bound closeOnboarding]} > <img @@ -1320,6 +1331,6 @@ preact-render-spy (1 nodes) exports[`<Onboarding /> when user is not logged in nothing should be rendered 1`] = ` preact-render-spy (1 nodes) ------- -{undefined} +{null} `; diff --git a/app/javascript/src/components/ItemList/ItemListItem.jsx b/app/javascript/src/components/ItemList/ItemListItem.jsx index c9ae10ae3..af6e8bd9a 100644 --- a/app/javascript/src/components/ItemList/ItemListItem.jsx +++ b/app/javascript/src/components/ItemList/ItemListItem.jsx @@ -32,8 +32,7 @@ export const ItemListItem = ({ item, children }) => { <span className="item-tags"> {adaptedItem.tags.map(tag => ( <a className="item-tag" href={`/t/${tag}`}> - # - {tag} + #{tag} </a> ))} </span> diff --git a/app/javascript/src/components/ItemList/ItemListTags.jsx b/app/javascript/src/components/ItemList/ItemListTags.jsx index 42226de1f..7234c7fca 100644 --- a/app/javascript/src/components/ItemList/ItemListTags.jsx +++ b/app/javascript/src/components/ItemList/ItemListTags.jsx @@ -10,8 +10,7 @@ export const ItemListTags = ({ availableTags, selectedTags, onClick }) => { data-no-instant onClick={e => onClick(e, tag)} > - # - {tag} + #{tag} </a> )); return <div className="tags">{tagsHTML}</div>; diff --git a/app/javascript/src/utils/getUnopenedChannels.jsx b/app/javascript/src/utils/getUnopenedChannels.jsx index a047484a8..8d299504d 100644 --- a/app/javascript/src/utils/getUnopenedChannels.jsx +++ b/app/javascript/src/utils/getUnopenedChannels.jsx @@ -63,9 +63,9 @@ class UnopenedChannelNotice extends Component { if (unopenedChannels.length === 0) { number.classList.remove('showing'); } else { - document.getElementById('connect-link').href = `/connect/${ - unopenedChannels[0].adjusted_slug - }`; + document.getElementById( + 'connect-link', + ).href = `/connect/${unopenedChannels[0].adjusted_slug}`; } setTimeout(() => { component.setState({ visible: false }); @@ -118,9 +118,7 @@ class UnopenedChannelNotice extends Component { padding: '19px 5px 14px', }} > - New Message from - {' '} - {channels} + New Message from {channels} </a> ); } @@ -134,7 +132,9 @@ function manageChannel(json) { if (json.length > 0) { number.classList.add('showing'); number.innerHTML = json.length; - document.getElementById('connect-link').href = `/connect/${json[0].adjusted_slug}`; // Jump the user directly to the channel where appropriate + document.getElementById( + 'connect-link', + ).href = `/connect/${json[0].adjusted_slug}`; // Jump the user directly to the channel where appropriate } else { number.classList.remove('showing'); } diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb index cabcfe69b..ae7186376 100644 --- a/app/policies/user_policy.rb +++ b/app/policies/user_policy.rb @@ -7,6 +7,10 @@ class UserPolicy < ApplicationPolicy true end + def onboarding_checkbox_update? + true + end + def update? current_user? end diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index afcda8209..e2a2e3ad4 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -39,7 +39,7 @@ <% if core_pages? %> <%= javascript_include_tag "base", defer: true %> <% if user_signed_in? %> - <%= javascript_pack_tag "Onboarding", defer: true %> + <%= javascript_pack_tag "onboardingRedirectCheck", defer: true %> <% end %> <% end %> <% if !core_pages? %> diff --git a/app/views/pages/_coc_text.html.erb b/app/views/pages/_coc_text.html.erb new file mode 100644 index 000000000..b58d24328 --- /dev/null +++ b/app/views/pages/_coc_text.html.erb @@ -0,0 +1,43 @@ + <p>All participants of The DEV Community are expected to abide by our Code of Conduct, both online and during in-person events that are hosted and/or associated with DEV.</p> + <h2>Our Pledge</h2> + <p>In the interest of fostering an open and welcoming environment, we as moderators of + <a href="https://dev.to">DEV</a> pledge to make participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. + </p> + <h2>Our Standards</h2> + <p>Examples of behavior that contributes to creating a positive environment include:</p> + <ul> + <li>Using welcoming and inclusive language</li> + <li>Being respectful of differing viewpoints and experiences</li> + <li>Referring to people by their preferred pronouns and using gender-neutral pronouns when uncertain</li> + <li>Gracefully accepting constructive criticism</li> + <li>Focusing on what is best for the community</li> + <li>Showing empathy towards other community members</li> + </ul> + <p>Examples of unacceptable behavior by participants include:</p> + <ul> + <li>The use of sexualized language or imagery and unwelcome sexual attention or advances</li> + <li>Trolling, insulting/derogatory comments, and personal or political attacks</li> + <li>Public or private harassment</li> + <li>Publishing others' private information, such as a physical or electronic address, without explicit permission</li> + <li>Other conduct which could reasonably be considered inappropriate in a professional setting</li> + <li>Dismissing or attacking inclusion-oriented requests</li> + </ul> + <p>We pledge to prioritize marginalized people’s safety over privileged people’s comfort. We will not act on complaints regarding:</p> + <ul> + <li>‘Reverse’ -isms, including ‘reverse racism,’ ‘reverse sexism,’ and ‘cisphobia’</li> + <li>Reasonable communication of boundaries, such as 'leave me alone,' 'go away,' or 'I’m not discussing this with you.'</li> + <li>Someone’s refusal to explain or debate social justice concepts</li> + <li>Criticisms of racist, sexist, cissexist, or otherwise oppressive behavior or assumptions</li> + </ul> + <h2>Enforcement</h2> + <p>Violations of the Code of Conduct may be reported by contacting the team via the + <a href="https://dev.to/report-abuse">abuse report form</a> or by sending an email to yo@dev.to. All reports will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. Further details of specific enforcement policies may be posted separately. + </p> + <p>Moderators have the right and responsibility to remove comments or other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any members for other behaviors that they deem inappropriate, threatening, offensive, or harmful.</p> + <h2>Attribution</h2> + <p>This Code of Conduct is adapted from:</p> + <ul> + <li><a href="http://contributor-covenant.org/version/1/4">Contributor Covenant, version 1.4</a></li> + <li><a href="http://www.writespeakcode.com/code-of-conduct.html">Write/Speak/Code</a></li> + <li><a href="https://geekfeminism.org/about/code-of-conduct/">Geek Feminism</a></li> + </ul> diff --git a/app/views/pages/_terms_text.html.erb b/app/views/pages/_terms_text.html.erb new file mode 100644 index 000000000..9ffe59ee1 --- /dev/null +++ b/app/views/pages/_terms_text.html.erb @@ -0,0 +1,132 @@ + <h3 id="terms"> + 1. Terms + </h3> + + <p> + By accessing this web site, you are agreeing to be bound by these + web site Terms and Conditions of Use, our + <a href="/privacy">Privacy Policy</a>, all applicable laws and regulations, + and agree that you are responsible for compliance with any applicable local + laws. If you do not agree with any of these terms, you are prohibited from + using or accessing this site. The materials contained in this web site are + protected by applicable copyright and trade mark law. + </p> + + <h3 id="use-licence"> + 2. Use License + </h3> + + <ol type="a"> + <li> + Permission is granted to temporarily download one copy of the materials + (information or software) on DEV's web site for personal, + non-commercial transitory viewing only. This is the grant of a license, + not a transfer of title, and under this license you may not: + + <ol type="i"> + <li>modify or copy the materials;</li> + <li>use the materials for any commercial purpose, or for any public display (commercial or non-commercial);</li> + <li>attempt to decompile or reverse engineer any software contained on DEV's web site;</li> + <li>remove any copyright or other proprietary notations from the materials; or</li> + <li>transfer the materials to another person or "mirror" the materials on any other server.</li> + </ol> + </li> + <li> + This license shall automatically terminate if you violate any of these restrictions and may be terminated by DEV at any time. Upon terminating your viewing of these materials or upon the termination of this license, you must destroy any downloaded materials in your possession whether in electronic or printed format. + </li> + </ol> + + <h3 id="disclaimer"> + 3. Disclaimer + </h3> + + <ol type="a"> + <li> + The materials on DEV's web site are provided "as is". DEV makes no warranties, expressed or implied, and hereby disclaims and negates all other warranties, including without limitation, implied warranties or conditions of merchantability, fitness for a particular purpose, or non-infringement of intellectual property or other violation of rights. Further, DEV does not warrant or make any representations concerning the accuracy, likely results, or reliability of the use of the materials on its Internet web site or otherwise relating to such materials or on any sites linked to this site. + </li> + </ol> + + <h3 id="limitations"> + 4. Limitations + </h3> + + <p> + In no event shall DEV or its suppliers be liable for any damages (including, without limitation, damages for loss of data or profit, or due to business interruption,) arising out of the use or inability to use the materials on DEV's Internet site, even if DEV or an authorized representative has been notified orally or in writing of the possibility of such damage. Because some jurisdictions do not allow limitations on implied warranties, or limitations of liability for consequential or incidental damages, these limitations may not apply to you. + </p> + + <h3 id="revisions-and-errata"> + 5. Revisions and Errata + </h3> + + <p> + The materials appearing on DEV's web site could include technical, typographical, or photographic errors. DEV does not warrant that any of the materials on its web site are accurate, complete, or current. DEV may make changes to the materials contained on its web site at any time without notice. DEV does not, however, make any commitment to update the materials. + </p> + + <h3 id="links"> + 6. Links + </h3> + + <p> + DEV has not reviewed all of the sites linked to its Internet web site and is not responsible for the contents of any such linked site. The inclusion of any link does not imply endorsement by DEV of the site. Use of any such linked web site is at the user's own risk. + </p> + + <h3 id="copyright-takedown"> + 7. Copyright / Takedown + </h3> + + <p> + Users agree and certify that they have rights to share all content that they post on dev.to — including, but not limited to, information posted in articles, discussions, and comments. This rule applies to prose, code snippets, collections of links, etc. Regardless of citation, users may not post copy and pasted content that does not belong to them. Users assume all risk for the content they post, including someone else's reliance on its accuracy, claims relating to intellectual property, or other legal rights. If you believe that a user has plagiarized content, misrepresented their identity, misappropriated work, or otherwise run afoul of DMCA regulations, please email + <a href="mailto:yo@dev.to">yo@dev.to</a>. DEV may remove any content users post for any reason. + </p> + + <h3 id="site-terms-of-use-modifications"> + 8. Site Terms of Use Modifications + </h3> + + <p> + DEV may revise these terms of use for its web site at any time without notice. By using this web site you are agreeing to be bound by the then current version of these Terms and Conditions of Use. + </p> + + <h3 id="dev-trademarks-and-logo-policy"> + 9. DEV Trademarks and Logos Policy + </h3> + + <p> + All uses of the DEV logo, DEV badges, brand slogans, iconography, and the like, may only be used with express permission from DEV. DEV reserves all rights, even if certain assets are included in DEV open source projects. Please contact yo@dev.to with any questions or to request permission. + </p> + + <h3 id="reserved-names"> + 10. Reserved Names + </h3> + + <p> + DEV has the right to maintain a list of reserved names which will not be made publicly available. These reserved names may be set aside for purposes of proactive trademark protection, avoiding user confusion, security measures, or any other reason (or no reason). + </p> + + <p> + Additionally, DEV reserves the right to change any already-claimed name at its sole discretion. In such cases, DEV will make reasonable effort to find a suitable alternative and assist with any transition-related concerns. + </p> + + <h3 id="content-policy"> + 11. Content Policy + </h3> + + <p> + Users must make a good-faith effort to share content that is on-topic, of high-quality, and is not designed primarily for the purposes of promotion or creating backlinks. Additionally, posts must contain substantial content — they may not merely reference an external link that contains the full post. This policy applies to comments, articles, and and all other works shared on the DEV platform. + </p> + + <p> + DEV reserves the right to remove any content that it deems to be in violation of this policy at its sole discretion. Additionally, DEV reserves the right to restrict any user’s ability to participate on the platform at its sole discretion. + </p> + + <h3 id="governing-law"> + 12. Governing Law + </h3> + + <p> + Any claim relating to DEV's web site shall be governed by the laws of the State of New York without regard to its conflict of law provisions. + </p> + + <p> + General Terms and Conditions applicable to Use of a Web Site. + </p> diff --git a/app/views/pages/code_of_conduct.html.erb b/app/views/pages/code_of_conduct.html.erb index 33ff6b710..ff3f6d670 100644 --- a/app/views/pages/code_of_conduct.html.erb +++ b/app/views/pages/code_of_conduct.html.erb @@ -23,48 +23,6 @@ <h1>Code of Conduct</h1> </div> <div class="body"> - <p>All participants of The DEV Community are expected to abide by our Code of Conduct, both online and during in-person events that are hosted and/or associated with DEV.</p> - <h2>Our Pledge</h2> - <p>In the interest of fostering an open and welcoming environment, we as moderators of - <a href="https://dev.to">DEV</a> pledge to make participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. - </p> - <h2>Our Standards</h2> - <p>Examples of behavior that contributes to creating a positive environment include:</p> - <ul> - <li>Using welcoming and inclusive language</li> - <li>Being respectful of differing viewpoints and experiences</li> - <li>Referring to people by their preferred pronouns and using gender-neutral pronouns when uncertain</li> - <li>Gracefully accepting constructive criticism</li> - <li>Focusing on what is best for the community</li> - <li>Showing empathy towards other community members</li> - </ul> - <p>Examples of unacceptable behavior by participants include:</p> - <ul> - <li>The use of sexualized language or imagery and unwelcome sexual attention or advances</li> - <li>Trolling, insulting/derogatory comments, and personal or political attacks</li> - <li>Public or private harassment</li> - <li>Publishing others' private information, such as a physical or electronic address, without explicit permission</li> - <li>Other conduct which could reasonably be considered inappropriate in a professional setting</li> - <li>Dismissing or attacking inclusion-oriented requests</li> - </ul> - <p>We pledge to prioritize marginalized people’s safety over privileged people’s comfort. We will not act on complaints regarding:</p> - <ul> - <li>‘Reverse’ -isms, including ‘reverse racism,’ ‘reverse sexism,’ and ‘cisphobia’</li> - <li>Reasonable communication of boundaries, such as 'leave me alone,' 'go away,' or 'I’m not discussing this with you.'</li> - <li>Someone’s refusal to explain or debate social justice concepts</li> - <li>Criticisms of racist, sexist, cissexist, or otherwise oppressive behavior or assumptions</li> - </ul> - <h2>Enforcement</h2> - <p>Violations of the Code of Conduct may be reported by contacting the team via the - <a href="https://dev.to/report-abuse">abuse report form</a> or by sending an email to yo@dev.to. All reports will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. Further details of specific enforcement policies may be posted separately. - </p> - <p>Moderators have the right and responsibility to remove comments or other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any members for other behaviors that they deem inappropriate, threatening, offensive, or harmful.</p> - <h2>Attribution</h2> - <p>This Code of Conduct is adapted from:</p> - <ul> - <li><a href="http://contributor-covenant.org/version/1/4">Contributor Covenant, version 1.4</a></li> - <li><a href="http://www.writespeakcode.com/code-of-conduct.html">Write/Speak/Code</a></li> - <li><a href="https://geekfeminism.org/about/code-of-conduct/">Geek Feminism</a></li> - </ul> + <%= render "coc_text" %> </div> </div> diff --git a/app/views/pages/onboarding.html.erb b/app/views/pages/onboarding.html.erb new file mode 100644 index 000000000..ae27e8284 --- /dev/null +++ b/app/views/pages/onboarding.html.erb @@ -0,0 +1,26 @@ +<% title "Welcome to #{ApplicationConfig["COMMUNITY_NAME"]}" %> + +<style> + <% cache "onboarding-css-#{ApplicationConfig['DEPLOYMENT_SIGNATURE']}", expires_in: 10.hours do %> + <%= Rails.application.assets["onboarding.css"].to_s.html_safe %> + <%= Rails.application.assets["preact/onboarding-modal.css"].to_s.html_safe %> + <% end %> + footer { + display: none; + } + .top-bar { + display: none; + } +</style> + +<div id="onboarding-container"> + <%= javascript_pack_tag "Onboarding", defer: true %> +</div> + +<div id="terms" style="display: none;"> + <%= render "terms_text" %> +</div> + +<div id="coc" style="display: none;"> + <%= render "coc_text" %> +</div> \ No newline at end of file diff --git a/app/views/pages/terms.html.erb b/app/views/pages/terms.html.erb index d18b6193c..956e4a22f 100644 --- a/app/views/pages/terms.html.erb +++ b/app/views/pages/terms.html.erb @@ -29,137 +29,6 @@ </h1> </div> <div class="body"> - <h3 id="terms"> - 1. Terms - </h3> - - <p> - By accessing this web site, you are agreeing to be bound by these - web site Terms and Conditions of Use, our - <a href="/privacy">Privacy Policy</a>, all applicable laws and regulations, - and agree that you are responsible for compliance with any applicable local - laws. If you do not agree with any of these terms, you are prohibited from - using or accessing this site. The materials contained in this web site are - protected by applicable copyright and trade mark law. - </p> - - <h3 id="use-licence"> - 2. Use License - </h3> - - <ol type="a"> - <li> - Permission is granted to temporarily download one copy of the materials - (information or software) on DEV's web site for personal, - non-commercial transitory viewing only. This is the grant of a license, - not a transfer of title, and under this license you may not: - - <ol type="i"> - <li>modify or copy the materials;</li> - <li>use the materials for any commercial purpose, or for any public display (commercial or non-commercial);</li> - <li>attempt to decompile or reverse engineer any software contained on DEV's web site;</li> - <li>remove any copyright or other proprietary notations from the materials; or</li> - <li>transfer the materials to another person or "mirror" the materials on any other server.</li> - </ol> - </li> - <li> - This license shall automatically terminate if you violate any of these restrictions and may be terminated by DEV at any time. Upon terminating your viewing of these materials or upon the termination of this license, you must destroy any downloaded materials in your possession whether in electronic or printed format. - </li> - </ol> - - <h3 id="disclaimer"> - 3. Disclaimer - </h3> - - <ol type="a"> - <li> - The materials on DEV's web site are provided "as is". DEV makes no warranties, expressed or implied, and hereby disclaims and negates all other warranties, including without limitation, implied warranties or conditions of merchantability, fitness for a particular purpose, or non-infringement of intellectual property or other violation of rights. Further, DEV does not warrant or make any representations concerning the accuracy, likely results, or reliability of the use of the materials on its Internet web site or otherwise relating to such materials or on any sites linked to this site. - </li> - </ol> - - <h3 id="limitations"> - 4. Limitations - </h3> - - <p> - In no event shall DEV or its suppliers be liable for any damages (including, without limitation, damages for loss of data or profit, or due to business interruption,) arising out of the use or inability to use the materials on DEV's Internet site, even if DEV or an authorized representative has been notified orally or in writing of the possibility of such damage. Because some jurisdictions do not allow limitations on implied warranties, or limitations of liability for consequential or incidental damages, these limitations may not apply to you. - </p> - - <h3 id="revisions-and-errata"> - 5. Revisions and Errata - </h3> - - <p> - The materials appearing on DEV's web site could include technical, typographical, or photographic errors. DEV does not warrant that any of the materials on its web site are accurate, complete, or current. DEV may make changes to the materials contained on its web site at any time without notice. DEV does not, however, make any commitment to update the materials. - </p> - - <h3 id="links"> - 6. Links - </h3> - - <p> - DEV has not reviewed all of the sites linked to its Internet web site and is not responsible for the contents of any such linked site. The inclusion of any link does not imply endorsement by DEV of the site. Use of any such linked web site is at the user's own risk. - </p> - - <h3 id="copyright-takedown"> - 7. Copyright / Takedown - </h3> - - <p> - Users agree and certify that they have rights to share all content that they post on dev.to — including, but not limited to, information posted in articles, discussions, and comments. This rule applies to prose, code snippets, collections of links, etc. Regardless of citation, users may not post copy and pasted content that does not belong to them. Users assume all risk for the content they post, including someone else's reliance on its accuracy, claims relating to intellectual property, or other legal rights. If you believe that a user has plagiarized content, misrepresented their identity, misappropriated work, or otherwise run afoul of DMCA regulations, please email - <a href="mailto:yo@dev.to">yo@dev.to</a>. DEV may remove any content users post for any reason. - </p> - - <h3 id="site-terms-of-use-modifications"> - 8. Site Terms of Use Modifications - </h3> - - <p> - DEV may revise these terms of use for its web site at any time without notice. By using this web site you are agreeing to be bound by the then current version of these Terms and Conditions of Use. - </p> - - <h3 id="dev-trademarks-and-logo-policy"> - 9. DEV Trademarks and Logos Policy - </h3> - - <p> - All uses of the DEV logo, DEV badges, brand slogans, iconography, and the like, may only be used with express permission from DEV. DEV reserves all rights, even if certain assets are included in DEV open source projects. Please contact yo@dev.to with any questions or to request permission. - </p> - - <h3 id="reserved-names"> - 10. Reserved Names - </h3> - - <p> - DEV has the right to maintain a list of reserved names which will not be made publicly available. These reserved names may be set aside for purposes of proactive trademark protection, avoiding user confusion, security measures, or any other reason (or no reason). - </p> - - <p> - Additionally, DEV reserves the right to change any already-claimed name at its sole discretion. In such cases, DEV will make reasonable effort to find a suitable alternative and assist with any transition-related concerns. - </p> - - <h3 id="content-policy"> - 11. Content Policy - </h3> - - <p> - Users must make a good-faith effort to share content that is on-topic, of high-quality, and is not designed primarily for the purposes of promotion or creating backlinks. Additionally, posts must contain substantial content — they may not merely reference an external link that contains the full post. This policy applies to comments, articles, and and all other works shared on the DEV platform. - </p> - - <p> - DEV reserves the right to remove any content that it deems to be in violation of this policy at its sole discretion. Additionally, DEV reserves the right to restrict any user’s ability to participate on the platform at its sole discretion. - </p> - - <h3 id="governing-law"> - 12. Governing Law - </h3> - - <p> - Any claim relating to DEV's web site shall be governed by the laws of the State of New York without regard to its conflict of law provisions. - </p> - - <p> - General Terms and Conditions applicable to Use of a Web Site. - </p> + <%= render "terms_text" %> </div> </div> diff --git a/config/initializers/reserved_words.rb b/config/initializers/reserved_words.rb index 7cc977a63..83896432b 100644 --- a/config/initializers/reserved_words.rb +++ b/config/initializers/reserved_words.rb @@ -25,6 +25,7 @@ class ReservedWords contact merch onboarding_update + onboarding_checkbox_update rlygenerator orlygenerator rlyslack diff --git a/config/routes.rb b/config/routes.rb index cf9d94190..05362cbd2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -166,6 +166,7 @@ Rails.application.routes.draw do get "/notification_subscriptions/:notifiable_type/:notifiable_id" => "notification_subscriptions#show" post "/notification_subscriptions/:notifiable_type/:notifiable_id" => "notification_subscriptions#upsert" patch "/onboarding_update" => "users#onboarding_update" + patch "/onboarding_checkbox_update" => "users#onboarding_checkbox_update" get "email_subscriptions/unsubscribe" post "/chat_channels/:id/moderate" => "chat_channels#moderate" post "/chat_channels/:id/open" => "chat_channels#open" @@ -249,6 +250,7 @@ Rails.application.routes.draw do get "/welcome" => "pages#welcome" get "/challenge" => "pages#challenge" get "/badge" => "pages#badge" + get "/onboarding" => "pages#onboarding" get "/shecoded" => "pages#shecoded" get "/💸", to: redirect("t/hiring") get "/security", to: "pages#bounty" diff --git a/db/migrate/20190603190201_add_onboarding_checklist_to_users.rb b/db/migrate/20190603190201_add_onboarding_checklist_to_users.rb new file mode 100644 index 000000000..651360a90 --- /dev/null +++ b/db/migrate/20190603190201_add_onboarding_checklist_to_users.rb @@ -0,0 +1,5 @@ +class AddOnboardingChecklistToUsers < ActiveRecord::Migration[5.2] + def change + add_column :users, :onboarding_checklist, :string, array: true, default: [] + end +end diff --git a/db/migrate/20190612174127_add_checked_terms_and_conditions_to_users.rb b/db/migrate/20190612174127_add_checked_terms_and_conditions_to_users.rb new file mode 100644 index 000000000..eb01bea34 --- /dev/null +++ b/db/migrate/20190612174127_add_checked_terms_and_conditions_to_users.rb @@ -0,0 +1,5 @@ +class AddCheckedTermsAndConditionsToUsers < ActiveRecord::Migration[5.2] + def change + add_column :users, :checked_terms_and_conditions, :bool, default: false + end +end diff --git a/db/migrate/20190717224405_add_last_onboarding_page_to_users.rb b/db/migrate/20190717224405_add_last_onboarding_page_to_users.rb new file mode 100644 index 000000000..92e5b1069 --- /dev/null +++ b/db/migrate/20190717224405_add_last_onboarding_page_to_users.rb @@ -0,0 +1,5 @@ +class AddLastOnboardingPageToUsers < ActiveRecord::Migration[5.2] + def change + add_column :users, :last_onboarding_page, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index d00268697..4516289fb 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -937,6 +937,7 @@ ActiveRecord::Schema.define(version: 2019_07_23_094834) do t.string "bg_color_hex" t.text "cached_chat_channel_memberships" t.boolean "checked_code_of_conduct", default: false + t.boolean "checked_terms_and_conditions", default: false t.integer "comments_count", default: 0, null: false t.string "config_font", default: "default" t.string "config_theme", default: "default" @@ -997,6 +998,7 @@ ActiveRecord::Schema.define(version: 2019_07_23_094834) do t.datetime "last_followed_at" t.datetime "last_moderation_notification", default: "2017-01-01 05:00:00" t.datetime "last_notification_activity" + t.string "last_onboarding_page" t.datetime "last_sign_in_at" t.inet "last_sign_in_ip" t.string "linkedin_url" @@ -1012,6 +1014,7 @@ ActiveRecord::Schema.define(version: 2019_07_23_094834) do t.string "name" t.string "old_old_username" t.string "old_username" + t.string "onboarding_checklist", default: [], array: true t.datetime "onboarding_package_form_submmitted_at" t.boolean "onboarding_package_fulfilled", default: false t.boolean "onboarding_package_requested", default: false diff --git a/spec/factories/users.rb b/spec/factories/users.rb index a996fab88..f1438982d 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -20,6 +20,8 @@ FactoryBot.define do website_url { Faker::Internet.url } confirmed_at { Time.current } saw_onboarding { true } + checked_code_of_conduct { true } + checked_terms_and_conditions { true } signup_cta_variant { "navbar_basic" } email_digest_periodic { false } diff --git a/spec/requests/follows_api_spec.rb b/spec/requests/follows_api_spec.rb index 85f780f10..d7eb1dca1 100644 --- a/spec/requests/follows_api_spec.rb +++ b/spec/requests/follows_api_spec.rb @@ -8,7 +8,7 @@ RSpec.describe "FollowsApi", type: :request do let(:user4) { create(:user) } let(:user5) { create(:user) } let(:users_hash) do - [{ id: user2.id }, { id: user3.id }, { id: user4.id }, { id: user5.id }].to_json + [{ id: user2.id }, { id: user3.id }, { id: user4.id }, { id: user5.id }] end it "returns empty if user not signed in" do @@ -27,7 +27,7 @@ RSpec.describe "FollowsApi", type: :request do run_background_jobs_immediately do post "/api/follows", params: { users: users_hash } end - expect(Follow.all.size).to eq(JSON.parse(users_hash).size) + expect(Follow.all.size).to eq(users_hash.size) end end end diff --git a/spec/system/user_logs_in_with_twitter_spec.rb b/spec/system/user_logs_in_with_twitter_spec.rb index 1a29157d6..a7dad69dc 100644 --- a/spec/system/user_logs_in_with_twitter_spec.rb +++ b/spec/system/user_logs_in_with_twitter_spec.rb @@ -39,8 +39,7 @@ RSpec.describe "Authenticating with twitter" do visit root_path click_link "Sign In With Twitter" - expect(page).to have_link("Write your first post now") - expect(page).to have_link("Welcome Thread") + expect(page.html).to include("onboarding-container") end it "logging in with twitter using invalid credentials" do