feat: render nav conditionally

This commit is contained in:
Ahmad Abdolsaheb
2020-09-03 14:21:16 +03:00
committed by Mrugesh Mohapatra
parent 422bacd15d
commit 1a66eac990
29 changed files with 640 additions and 180 deletions

View File

@@ -1,13 +1,19 @@
/* global expect */
import React from 'react';
import ShallowRenderer from 'react-test-renderer/shallow';
import TestRenderer from 'react-test-renderer';
import { UniversalNav } from './components/UniversalNav';
import NavLinks from './components/NavLinks';
import renderer from 'react-test-renderer';
import { forumLocation } from '../../../config/env.json';
import { UniversalNav } from './components/UniversalNav';
import { AuthOrProfile } from './components/NavLinks';
describe('<UniversalNav />', () => {
const UniversalNavProps = {
displayMenu: false,
menuButtonRef: {},
searchBarRef: {},
toggleDisplayMenu: function() {},
pathName: '/'
};
it('renders to the DOM', () => {
const shallow = new ShallowRenderer();
shallow.render(<UniversalNav {...UniversalNavProps} />);
@@ -15,34 +21,138 @@ describe('<UniversalNav />', () => {
expect(result).toBeTruthy();
});
});
describe('<NavLinks />', () => {
const root = TestRenderer.create(<NavLinks />).root;
const aTags = root.findAllByType('a');
it('shows Curriculum and Sign In buttons on landing page', () => {
const landingPageProps = {
user: {
username: 'test-user',
picture: 'https://freecodecamp.org/image.png'
},
pending: false,
pathName: '/'
};
const shallow = new ShallowRenderer();
shallow.render(<AuthOrProfile {...landingPageProps} />);
const result = shallow.getRenderOutput();
// expect(result.props.children).toEqual('Sign In');
// reduces the aTags to href links
const links = aTags.reduce((acc, item) => {
acc.push(item._fiber.pendingProps.href);
return acc;
}, []);
expect(deepChildrenProp(result, 0).children === 'Curriculum').toBeTruthy();
const expectedLinks = ['/learn', '/news', forumLocation];
it('renders to the DOM', () => {
expect(root).toBeTruthy();
});
it('has 3 links', () => {
expect(aTags.length === 3).toBeTruthy();
expect(
result.props.children[1].props['data-test-label'] === 'landing-small-cta'
).toBeTruthy();
});
it('has links to news, forum, learn and portfolio', () => {
// checks if all links in expected links exist in links
expect(expectedLinks.every(elem => links.indexOf(elem) > -1)).toBeTruthy();
it('has Curriculum and Portfolio links when user signed in on /learn', () => {
const defaultUserProps = {
user: {
username: 'test-user',
picture: 'https://freecodecamp.org/image.png',
isDonating: true
},
pending: false,
pathName: '/learn'
};
const shallow = new ShallowRenderer();
shallow.render(<AuthOrProfile {...defaultUserProps} />);
const result = shallow.getRenderOutput();
expect(hasCurriculumNavItem(result)).toBeTruthy();
expect(hasProfileNavItem(result)).toBeTruthy();
});
it('has avatar with default border for default users', () => {
const defaultUserProps = {
user: {
username: 'test-user',
picture: 'https://freecodecamp.org/image.png'
},
pending: false,
pathName: '/learn'
};
const componentTree = renderer
.create(<AuthOrProfile {...defaultUserProps} />)
.toJSON();
expect(avatarHasClass(componentTree, 'default-border')).toBeTruthy();
});
it('has avatar with gold border for donating users', () => {
const donatingUserProps = {
user: {
username: 'test-user',
picture: 'https://freecodecamp.org/image.png',
isDonating: true
},
pending: false,
pathName: '/learn'
};
const componentTree = renderer
.create(<AuthOrProfile {...donatingUserProps} />)
.toJSON();
expect(avatarHasClass(componentTree, 'gold-border')).toBeTruthy();
});
it('has avatar with green border for top contributors', () => {
const topContributorUserProps = {
user: {
username: 'test-user',
picture: 'https://freecodecamp.org/image.png',
yearsTopContributor: [2020]
},
pending: false,
pathName: '/learn'
};
const componentTree = renderer
.create(<AuthOrProfile {...topContributorUserProps} />)
.toJSON();
expect(avatarHasClass(componentTree, 'green-border')).toBeTruthy();
});
it('has avatar with purple border for donating top contributors', () => {
const topDonatingContributorUserProps = {
user: {
username: 'test-user',
picture: 'https://freecodecamp.org/image.png',
isDonating: true,
yearsTopContributor: [2020]
},
pending: false,
pathName: '/learn'
};
const componentTree = renderer
.create(<AuthOrProfile {...topDonatingContributorUserProps} />)
.toJSON();
expect(avatarHasClass(componentTree, 'purple-border')).toBeTruthy();
});
});
const UniversalNavProps = {
displayMenu: false,
menuButtonRef: {},
searchBarRef: {},
toggleDisplayMenu: function() {}
const deepChildrenProp = (component, childNumber) =>
component.props.children[childNumber].props.children.props;
const hasProfileNavItem = component => {
const profileElement = deepChildrenProp(component, 1);
return (
profileElement.children[0] === 'Profile' &&
profileElement.to === '/test-user'
);
};
const hasCurriculumNavItem = component => {
const curriculumElement = deepChildrenProp(component, 0);
return (
curriculumElement.children === 'Curriculum' &&
curriculumElement.to === '/learn'
);
};
const avatarHasClass = (componentTree, classes) => {
return (
componentTree[1].children[0].children[1].props.className ===
'avatar-container ' + classes
);
};

View File

@@ -0,0 +1,65 @@
/* eslint-disable react/sort-prop-types */
import React from 'react';
import {
Link,
borderColorPicker,
SkeletonSprite,
AvatarRenderer
} from '../../helpers';
import PropTypes from 'prop-types';
import Login from '../components/Login';
const propTypes = {
pending: PropTypes.bool,
pathName: PropTypes.string.isRequired,
user: PropTypes.object
};
export function AuthOrProfile({ user, pathName, pending }) {
const isUserDonating = user && user.isDonating;
const isUserSignedIn = user && user.username;
const isTopContributor =
user && user.yearsTopContributor && user.yearsTopContributor.length > 0;
const badgeColorClass = borderColorPicker(isUserDonating, isTopContributor);
if (pending && pathName !== '/') {
return (
<div className='nav-skeleton'>
<SkeletonSprite />
</div>
);
} else if (pathName === '/' || !isUserSignedIn) {
return <Login data-test-label='landing-small-cta'>Sign In</Login>;
} else {
return (
<>
<li>
<Link className='nav-link' to='/learn'>
Curriculum
</Link>
</li>
<li>
<Link className='nav-link' to={`/${user.username}`}>
Profile
</Link>
<Link
className={`avatar-nav-link ${badgeColorClass}`}
to={`/${user.username}`}
>
<AvatarRenderer
picture={user.picture}
size='sm'
userName={user.username}
/>
</Link>
</li>
</>
);
}
}
AuthOrProfile.propTypes = propTypes;
AuthOrProfile.displayName = 'AuthOrProfile';
export default AuthOrProfile;

View File

@@ -1,30 +1,68 @@
import React from 'react';
import { Link } from '../../helpers';
import { forumLocation } from '../../../../../config/env.json';
import { Link, SkeletonSprite, AvatarRenderer } from '../../helpers';
import PropTypes from 'prop-types';
import Login from '../components/Login';
const propTypes = {
displayMenu: PropTypes.bool
displayMenu: PropTypes.bool,
fetchState: PropTypes.shape({ pending: PropTypes.bool }),
pathName: PropTypes.string.isRequired,
user: PropTypes.object
};
function NavLinks({ displayMenu }) {
export function AuthOrProfile({ user, pathName, pending }) {
const isUserDonating = user && user.isDonating;
const isUserSignedIn = user && user.username;
const isTopContributor =
user && user.yearsTopContributor && user.yearsTopContributor.length > 0;
if (pending && pathName !== '/') {
return (
<div className='nav-skeleton'>
<SkeletonSprite />
</div>
);
} else if (pathName === '/' || !isUserSignedIn) {
return (
<>
<li>
<Link className='nav-link' to='/learn'>
Curriculum
</Link>
</li>
<Login data-test-label='landing-small-cta'>Sign In</Login>
</>
);
} else {
return (
<>
<li>
<Link className='nav-link' to='/learn'>
Curriculum
</Link>
</li>
<li>
<Link className='nav-link' to={`/${user.username}`}>
Profile
<AvatarRenderer
isDonating={isUserDonating}
isTopContributor={isTopContributor}
picture={user.picture}
userName={user.username}
/>
</Link>
</li>
</>
);
}
}
export function NavLinks({ displayMenu, pathName, user, fetchState }) {
const { pending } = fetchState;
return (
<div className='main-nav-group'>
<ul className={'nav-list' + (displayMenu ? ' display-flex' : '')}>
<li className='nav-news'>
<Link external={true} sameTab={true} to='/news'>
/news
</Link>
</li>
<li className='nav-forum'>
<Link external={true} sameTab={true} to={forumLocation}>
/forum
</Link>
</li>
<li className='nav-projects'>
<Link to='/learn'>/learn</Link>
</li>
<AuthOrProfile pathName={pathName} pending={pending} user={user} />
</ul>
</div>
);

View File

@@ -12,7 +12,10 @@ export const UniversalNav = ({
displayMenu,
toggleDisplayMenu,
menuButtonRef,
searchBarRef
searchBarRef,
pathName,
user,
fetchState
}) => (
<nav
className={'universal-nav nav-padding' + (displayMenu ? ' expand-nav' : '')}
@@ -30,7 +33,12 @@ export const UniversalNav = ({
</Link>
</div>
<div className='universal-nav-right main-nav'>
<NavLinks displayMenu={displayMenu} />
<NavLinks
displayMenu={displayMenu}
fetchState={fetchState}
pathName={pathName}
user={user}
/>
</div>
<MenuButton
displayMenu={displayMenu}
@@ -45,7 +53,10 @@ export default UniversalNav;
UniversalNav.propTypes = {
displayMenu: PropTypes.bool,
fetchState: PropTypes.shape({ pending: PropTypes.bool }),
menuButtonRef: PropTypes.object,
pathName: PropTypes.string.isRequired,
searchBarRef: PropTypes.object,
toggleDisplayMenu: PropTypes.func
toggleDisplayMenu: PropTypes.func,
user: PropTypes.object
};

View File

@@ -68,10 +68,11 @@
margin: 0 0 0 -12px;
padding: 0;
list-style: none;
align-items: center;
}
.nav-list {
height: 38px;
height: var(--header-height);
}
.nav-list li {
@@ -80,37 +81,66 @@
padding: 0;
}
.nav-list li a {
display: block;
.nav-list li a.nav-link {
display: flex;
margin: 0;
padding: 6px 15px;
padding: 0 15px;
color: var(--gray-00);
opacity: 1;
white-space: nowrap;
height: 38px;
height: var(--header-height);
align-items: center;
}
.nav-list li a:hover {
.nav-list li:last-child {
display: flex;
flex-direction: row;
}
.nav-list li:last-child a {
padding-right: 15px;
}
.nav-list li a.nav-link:hover {
color: var(--theme-color);
text-decoration: none;
background: white;
}
.nav-list li a:focus {
.nav-list li a.nav-link:focus {
background: var(--theme-color);
color: white;
}
.nav-list li a:focus:hover {
.nav-list li a.nav-link:focus:hover {
color: var(--theme-color);
background: white;
}
.nav-list a.btn-cta {
padding: 0 20px;
height: 30px;
margin-left: 19px;
margin-right: 15px;
}
.nav-skeleton {
height: var(--header-height);
margin-right: 15px;
width: 200px;
}
.nav-list .fcc-loader {
padding: 0 40px;
margin-left: 35px;
margin-right: 25px;
}
.universal-nav-right {
flex-shrink: 0;
display: flex;
align-items: center;
height: 38px;
height: var(--header-height);
}
.toggle-button-nav {
@@ -133,8 +163,46 @@
border: 1px solid var(--gray-00);
}
.nav-list li .avatar-container {
display: block;
padding: 0;
margin-left: 15px;
opacity: 1;
white-space: nowrap;
background: transparent;
height: calc(var(--header-height) - 8px);
width: calc(var(--header-height) - 8px);
}
.nav-list li .avatar-containersvg {
display: inline-block;
background: var(--secondary-background);
}
.nav-list .avatar-container svg,
.nav-list .avatar-container img {
object-fit: cover;
width: 100%;
height: 100%;
}
.nav-list .gold-border {
border: 2px solid var(--yellow-gold);
}
.nav-list .green-border {
border: 2px solid var(--green-mid);
}
.nav-list .purple-border {
border: 2px solid var(--purple-mid);
}
.nav-list .default-border {
border: 2px solid transparent;
}
@media (max-width: 300px) {
.nav-list li a {
.nav-list li a.nav-link {
width: 50vw;
text-align: center;
}

View File

@@ -1,11 +1,18 @@
import React from 'react';
import Helmet from 'react-helmet';
import PropTypes from 'prop-types';
import stripeObserver from './stripeIframesFix';
import UniversalNav from './components/UniversalNav';
import './header.css';
const propTypes = {
fetchState: PropTypes.shape({ pending: PropTypes.bool }),
pathName: PropTypes.string.isRequired,
user: PropTypes.object
};
export class Header extends React.Component {
constructor(props) {
super(props);
@@ -47,6 +54,7 @@ export class Header extends React.Component {
}
render() {
const { displayMenu } = this.state;
const { fetchState, pathName, user } = this.props;
return (
<>
<Helmet>
@@ -55,9 +63,12 @@ export class Header extends React.Component {
<header>
<UniversalNav
displayMenu={displayMenu}
fetchState={fetchState}
menuButtonRef={this.menuButtonRef}
pathName={pathName}
searchBarRef={this.searchBarRef}
toggleDisplayMenu={this.toggleDisplayMenu}
user={user}
/>
</header>
</>
@@ -65,5 +76,7 @@ export class Header extends React.Component {
}
}
Header.propTypes = propTypes;
Header.displayName = 'Header';
export default Header;

View File

@@ -21,7 +21,6 @@ const propTypes = {
function Intro({
isSignedIn,
username,
name,
pending,
complete,
@@ -49,13 +48,7 @@ function Intro({
<Spacer />
</Col>
</Row>
<FullWidthRow className='button-group'>
<Link
className='btn btn-lg btn-primary btn-block'
to={`/${username}`}
>
View my Portfolio
</Link>
<FullWidthRow>
<Link className='btn btn-lg btn-primary btn-block' to='/settings'>
Update my account settings
</Link>

View File

@@ -0,0 +1,44 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Image } from '@freecodecamp/react-bootstrap';
import DefaultAvatar from '../../assets/icons/DefaultAvatar';
import { defaultUserImage } from '../../../../config/misc';
const propTypes = {
isDonating: PropTypes.bool,
isTopContributor: PropTypes.bool,
picture: PropTypes.any.isRequired,
userName: PropTypes.string.isRequired
};
function borderColorPicker(isDonating, isTopContributor) {
if (isDonating && isTopContributor) return 'purple-border';
else if (isTopContributor) return 'green-border';
else if (isDonating) return 'gold-border';
else return 'default-border';
}
function AvatarRenderer({ picture, userName, isDonating, isTopContributor }) {
let borderColor = borderColorPicker(isDonating, isTopContributor);
let isPlaceHolderImage =
/example.com|identicon.org/.test(picture) || picture === defaultUserImage;
return (
<div className={`avatar-container ${borderColor}`}>
{isPlaceHolderImage ? (
<DefaultAvatar className='avatar default-avatar' />
) : (
<Image
alt={userName + "'s avatar"}
className='avatar'
responsive={true}
src={picture}
/>
)}
</div>
);
}
AvatarRenderer.propTypes = propTypes;
AvatarRenderer.displayName = 'AvatarRenderer';
export default AvatarRenderer;

View File

@@ -9,9 +9,9 @@ function SkeletonSprite() {
<svg className='sprite-svg'>
<rect
className='sprite'
fill='#ccc'
fill='var(--gray-75)'
height='100%'
stroke='#ccc'
stroke='var(--gray-75)'
width='2px'
x='0'
y='0'

View File

@@ -0,0 +1,6 @@
export default function borderColorPicker(isDonating, isTopContributor) {
if (isDonating && isTopContributor) return 'purple-border';
else if (isTopContributor) return 'green-border';
else if (isDonating) return 'gold-border';
else return 'default-border';
}

View File

@@ -7,3 +7,5 @@ export { default as Spacer } from './Spacer';
export { default as Link } from './Link';
export { default as CurrentChallengeLink } from './CurrentChallengeLink';
export { default as ImageLoader } from './ImageLoader';
export { default as AvatarRenderer } from './AvatarRenderer';
export { default as borderColorPicker } from './borderColorPicker';

View File

@@ -8,7 +8,7 @@ export default `
.sprite-svg {
height: 100%;
width: 100%;
background: #aaa;
background: var(--theme-color);
}
@@ -18,8 +18,15 @@ export default `
transform: translateX(0%);
stroke-width: 2px;
}
5%{
opacity:100%;
}
35% {
stroke-width: 30px;
opacity:100%;
}
65%{
opacity:100%;
}
100% {
-webkit-transform: translateX(100%);
@@ -36,6 +43,10 @@ export default `
}
35% {
stroke-width: 30px;
opacity:100%;
}
65%{
opacity:100%;
}
100% {
-webkit-transform: translateX(100%);
@@ -45,8 +56,10 @@ export default `
}
.sprite {
opacity:0%;
-webkit-animation-name: shimmer;
animation-name: shimmer;
animation-delay: 1s;
width: 2px;
-webkit-animation-duration: 2s;
animation-duration: 2s;

View File

@@ -11,7 +11,9 @@ import {
isSignedInSelector,
onlineStatusChange,
isOnlineSelector,
userFetchStateSelector,
userSelector,
usernameSelector,
executeGA
} from '../../redux';
import { flashMessageSelector, removeFlashMessage } from '../Flash/redux';
@@ -69,6 +71,7 @@ const metaKeywords = [
const propTypes = {
children: PropTypes.node.isRequired,
executeGA: PropTypes.func,
fetchState: PropTypes.shape({ pending: PropTypes.bool }),
fetchUser: PropTypes.func.isRequired,
flashMessage: PropTypes.shape({
id: PropTypes.string,
@@ -82,21 +85,27 @@ const propTypes = {
pathname: PropTypes.string.isRequired,
removeFlashMessage: PropTypes.func.isRequired,
showFooter: PropTypes.bool,
signedInUserName: PropTypes.string,
theme: PropTypes.string,
useTheme: PropTypes.bool
useTheme: PropTypes.bool,
user: PropTypes.object
};
const mapStateToProps = createSelector(
isSignedInSelector,
flashMessageSelector,
isOnlineSelector,
userFetchStateSelector,
userSelector,
(isSignedIn, flashMessage, isOnline, user) => ({
usernameSelector,
(isSignedIn, flashMessage, isOnline, fetchState, user) => ({
isSignedIn,
flashMessage,
hasMessage: !!flashMessage.message,
isOnline,
theme: user.theme
fetchState,
theme: user.theme,
user
})
);
@@ -142,14 +151,18 @@ class DefaultLayout extends Component {
const {
children,
hasMessage,
fetchState,
flashMessage,
isOnline,
isSignedIn,
removeFlashMessage,
showFooter = true,
theme = 'default',
useTheme = true
user,
useTheme = true,
pathname
} = this.props;
return (
<Fragment>
<Helmet
@@ -213,7 +226,7 @@ class DefaultLayout extends Component {
<style>{fontawesome.dom.css()}</style>
</Helmet>
<WithInstantSearch>
<Header />
<Header fetchState={fetchState} pathName={pathname} user={user} />
<div className={`default-layout`}>
<OfflineWarning isOnline={isOnline} isSignedIn={isSignedIn} />
{hasMessage && flashMessage ? (

View File

@@ -225,7 +225,7 @@ fieldset[disabled] .btn-primary.focus {
.btn-cta:hover,
.btn-cta:focus,
.btn-cta:active:hover {
background-color: #fecc4c;
background-color: #fecc4c !important;
border-width: 3px;
border-color: #f1a02a;
background-image: none;

View File

@@ -17,6 +17,8 @@
--blue-light: #99c9ff;
--blue-dark: #002ead;
--green-light: #acd157;
--green-mid: green;
--purple-mid: darkviolet;
--green-dark: #00471b;
--red-light: #ffadad;
--red-dark: #850000;

View File

@@ -21,7 +21,7 @@ exports[`<Profile/> renders correctly 1`] = `
class="avatar-container col-xs-12"
>
<div
class=""
class="avatar-container default-border"
>
<img
alt="string's avatar"

View File

@@ -1,13 +1,14 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Col, Row, Image } from '@freecodecamp/react-bootstrap';
import { Col, Row } from '@freecodecamp/react-bootstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faAward,
faHeart,
faCalendar
} from '@fortawesome/free-solid-svg-icons';
import Identicon from 'react-identicons';
import { AvatarRenderer } from '../../helpers';
import SocialIcons from './SocialIcons';
@@ -77,29 +78,16 @@ function Camper({
twitter,
website
}) {
// A lot of the user-profiles are still using the defunct service.
const avatar = /example.com|identicon.org/.test(picture) ? (
<Identicon
bg={'#858591'}
count={5}
fg={'#0A0A23'}
padding={5}
size={256}
string={username}
/>
) : (
<Image
alt={username + "'s avatar"}
className='avatar'
responsive={true}
src={picture}
/>
);
return (
<div>
<Row>
<Col className='avatar-container' xs={12}>
<div className={isDonating ? 'supporter-img' : ''}>{avatar}</div>
<AvatarRenderer
isDonating={isDonating}
isTopContributor={yearsTopContributor.length > 0}
picture={picture}
userName={username}
/>
</Col>
</Row>
<SocialIcons

View File

@@ -1,5 +1,7 @@
.avatar-container .avatar {
height: 180px;
width: 180px;
object-fit: cover;
}
.avatar-container {
@@ -14,6 +16,22 @@
overflow-wrap: break-word;
}
.supporter-img {
.avatar-container div {
height: 200px;
}
.avatar-container .gold-border {
border: 10px solid var(--yellow-gold);
}
.avatar-container .green-border {
border: 10px solid var(--green-mid);
}
.avatar-container .purple-border {
border: 10px solid var(--purple-mid);
}
.avatar-container .default-border {
border: 10px solid transparent;
}