📑Profile list (#11)

* edit github workflows

* document Altert type

* add firestore reducer

* connect developers profile to store

* switch picture field to avatarUrl

* handle document uid

* add param to profile route

* use id parameter for profile

* redirect to notfound page if dev is null

* wait for profile to be loaded before displaying profile

* add Dev class, IDev interface, remove blankDev and getDescription method

* profile-top

* format social links

* profile-about

* profile description

* add placeholders to profile

* alt tag on placeholders

* deploy.yml
This commit is contained in:
Ruidy 2020-05-17 19:15:27 +02:00 committed by GitHub
parent 75c9888493
commit 309ee76a32
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 313 additions and 163 deletions

View file

@ -29,6 +29,15 @@ jobs:
name: Release
if: ${{ github.ref == 'refs/heads/master' }}
runs-on: ubuntu-latest
env:
REACT_APP_STORAGE_BUCKET: ${{ secrets.REACT_APP_STORAGE_BUCKET }}
REACT_APP_PROJECT_ID: ${{ secrets.REACT_APP_PROJECT_ID }}
REACT_APP_MSG_SENDER_ID: ${{ secrets.REACT_APP_MSG_SENDER_ID }}
REACT_APP_MEASUREMENT_ID: ${{ secrets.REACT_APP_MEASUREMENT_ID }}
REACT_APP_DB_URL: ${{ secrets.REACT_APP_DB_URL }}
REACT_APP_AUTH_DOMAIN: ${{ secrets.REACT_APP_AUTH_DOMAIN }}
REACT_APP_APP_ID: ${{ secrets.REACT_APP_APP_ID }}
REACT_APP_API_KEY: ${{ secrets.REACT_APP_API_KEY }}
steps:
- uses: actions/checkout@v2
- name: Install dependencies

View file

@ -1,7 +1,12 @@
import React, {FC} from 'react';
// Routing
import {Link} from 'react-router-dom';
// Style
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faCheck} from '@fortawesome/free-solid-svg-icons';
import {DevSummary} from '../models/Dev';
// Typing
import {DevSummary, getDescription} from '../models/Dev';
import Routes from '../constants/routes';
/**
* Present a dev profile succintly. Redirect to dev profile on click.
@ -10,23 +15,24 @@ import {DevSummary} from '../models/Dev';
const DevProfile: FC<DevSummary> = ({
id,
displayName,
picture,
description,
avatarUrl,
status,
company,
location,
skills,
}) => (
<div className="profile bg-light">
<img src={picture} alt={displayName} className="round-img" />
<img src={avatarUrl} alt={displayName} className="round-img" />
<div>
<h2>{displayName}</h2>
<p>{description}</p>
<p>{getDescription(status, company)}</p>
<p>{location}</p>
<a href="profile.html" className="btn btn-primary">
<Link to={`${Routes.PROFILE}/${id}`} className="btn btn-primary">
View Profile
</a>
</Link>
</div>
<ul>
{skills.map((s, i) => (
{skills?.map((s, i) => (
<li className="text-primary" key={i}>
<FontAwesomeIcon icon={faCheck} /> {s}
</li>

View file

@ -5,10 +5,12 @@ import Repo from '../types/Repo';
/** Shorter dev interface */
export interface DevSummary {
id: string;
id?: string;
displayName: string;
picture: string;
avatarUrl: string;
description: string;
status: string;
company: string;
location: string;
skills: string[];
}
@ -16,33 +18,40 @@ export interface DevSummary {
/** Full developer profile information.
* @extends DevSummary to avoid duplication
*/
interface Dev extends DevSummary {
interface IDev extends DevSummary {
isActive: boolean;
bio: string;
status: string;
company: string;
github: string;
links: Links;
experiences: Experience[];
educations: Education[];
repos: Repo[];
}
/** create profile tagline */
export const getDescription = (status: string, company: string) =>
`${status} at ${company}`;
export const getDescription = (status?: string, company?: string): string => {
if (status && company) return `${status} at ${company}`;
if (status) return status;
if (company) return `Employed at ${company}`;
return 'DevBook Member';
};
/** blank Dev serve as placeholder when initializing a new profile */
export const blankDev: Dev = {
id: '42',
isActive: true,
displayName: '',
status: 'Developer',
company: '',
picture: '',
description: '',
location: '',
skills: [],
links: {
/** class implementing IDev.
* No constructor is provided.
* new Dev() returns a placeholder used when initializing a new profile.
* id is not specified to not overwrite document uid.
*/
export class Dev implements IDev {
id?: string;
isActive = true;
displayName = '';
status = 'Developer';
company = '';
avatarUrl = '';
description = '';
location = '';
skills: string[] = [];
github: string = '';
links: Links = {
website: '',
instagram: '',
facebook: '',
@ -50,27 +59,28 @@ export const blankDev: Dev = {
twitter: '',
github: '',
youtube: '',
},
bio: '',
experiences: [],
educations: [],
repos: [],
};
};
bio = '';
experiences: Experience[] = [];
educations: Education[] = [];
repos: Repo[] = [];
}
/**
* sample Dev for development and tests
*/
export const dummyDev: Dev = {
export const dummyDev: IDev = {
id: '0',
isActive: true,
displayName: 'John Doe',
status: 'Developer',
company: 'Microsoft',
picture:
avatarUrl:
'https://www.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50?s=200',
description: 'Developer at Microsoft',
location: 'Seattle, WA',
skills: ['HTML', 'CSS', 'JavaScript', 'Python'],
github: '',
links: {
website: '#',
instagram: 'http://insta.com',
@ -146,4 +156,32 @@ export const dummyDev: Dev = {
},
],
};
export default Dev;
/** dummy devSummary profiles for debug and development only */
export const developers: DevSummary[] = [
{
id: '0',
displayName: 'John Doe',
avatarUrl:
'https://www.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50?s=200',
description: 'Developer at Microsoft',
location: 'Seattle, WA',
skills: ['HTML', 'CSS', 'JavaScript', 'Python'],
status: 'Developer',
company: 'Microsoft',
},
{
id: '42',
displayName: 'Ruidy Nemausat',
avatarUrl:
'https://lh3.googleusercontent.com/a-/AOh14GhncH95MWKwPR3TRKy4eVd4n6w0-fobe4dhiam2xA',
description: 'Fullstack Engineer at DESY',
location: 'Hamburg, DE',
skills: ['React', 'TypeScript', 'Redux', 'Nodejs'],
status: 'Developer',
company: 'Microsoft',
},
];
export default IDev;

View file

@ -1,49 +1,39 @@
import React, {FC} from 'react';
// Redux
import {compose} from 'redux';
import {connect} from 'react-redux';
import {firestoreConnect} from 'react-redux-firebase';
import {RootState} from '../store';
// Style
import Header from '../components/Header';
import DevProfile from '../components/DevProfile';
import {DevSummary} from '../models/Dev';
interface IProps {
developers: DevSummary[];
}
/**
* Developers list page
*/
// const Developers: FC<DevSummary[]> = (developers) => {
const Developers: FC = () => {
const developers: DevSummary[] = [
{
id: '0',
displayName: 'John Doe',
picture:
'https://www.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50?s=200',
description: 'Developer at Microsoft',
location: 'Seattle, WA',
skills: ['HTML', 'CSS', 'JavaScript', 'Python'],
},
{
id: '42',
displayName: 'Ruidy Nemausat',
picture:
'https://lh3.googleusercontent.com/a-/AOh14GhncH95MWKwPR3TRKy4eVd4n6w0-fobe4dhiam2xA',
description: 'Fullstack Engineer at DESY',
location: 'Hamburg, DE',
skills: ['React', 'TypeScript', 'Redux', 'Nodejs'],
},
];
const Developers: FC<IProps> = ({developers}) => (
<section className="container">
<Header
title="Developers"
lead="Browse and connect with developers"
icon="connectdevelop"
/>
<div className="profiles">
{developers?.map(dev => (
// use spread operator to pass props
<DevProfile key={dev.id} {...dev} />
))}
</div>
</section>
);
return (
<section className="container">
<Header
title="Developers"
lead="Browse and connect with developers"
icon="connectdevelop"
/>
<div className="profiles">
{developers.map(dev => (
// use spread operator to pass props
<DevProfile key={dev.id} {...dev} />
))}
</div>
</section>
);
};
export default Developers;
export default compose<FC>(
firestoreConnect(() => ['users']), // or { collection: 'users' }
connect((state: RootState, props) => ({
developers: state.firestore.ordered.users,
})),
)(Developers);

View file

@ -21,7 +21,7 @@ import useForm from '../hooks';
// Typing
import Dev from '../models/Dev';
import User from '../models/User';
import Links from '../types/Links';
import Links, {parseLink, getGithubLink} from '../types/Links';
import IAlert, {formAlert} from '../types/Alert';
interface FormData {
@ -52,6 +52,7 @@ const EditProfile: FC<IProps> = ({
links,
location,
bio,
github,
}) => {
const [showLinks, setShowLinks] = useState(false);
const [alert, setAlert] = useState<IAlert>(formAlert);
@ -63,7 +64,7 @@ const EditProfile: FC<IProps> = ({
bio: bio ?? '',
skills: skills?.toString() ?? '',
website: links?.website ?? '',
github: links?.github ?? '',
github: github ?? '',
facebook: links?.facebook ?? '',
linkedin: links?.linkedin ?? '',
instagram: links?.instagram ?? '',
@ -89,13 +90,13 @@ const EditProfile: FC<IProps> = ({
skills,
}: FormData) => {
const newLinks: Links = {
website,
instagram,
facebook,
linkedin,
twitter,
github,
youtube,
website: parseLink(website),
instagram: parseLink(instagram),
facebook: parseLink(facebook),
linkedin: parseLink(linkedin),
twitter: parseLink(twitter),
github: getGithubLink(github),
youtube: parseLink(youtube),
};
const newSkills: string[] = skills?.split(',');
return {
@ -103,6 +104,7 @@ const EditProfile: FC<IProps> = ({
company,
location,
bio,
github,
links: newLinks,
skills: newSkills,
};

View file

@ -1,4 +1,14 @@
import React, {FC} from 'react';
// Redux
import {compose} from '@reduxjs/toolkit';
import {firestoreConnect} from 'react-redux-firebase';
import {connect} from 'react-redux';
import {RootState} from '../store';
// Routing
import {Link, useParams} from 'react-router-dom';
import Routes from '../constants/routes';
import NotFound from './NotFound';
// Style
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {
faGithub,
@ -6,6 +16,7 @@ import {
faInstagram,
faLinkedin,
faTwitter,
faYoutube,
} from '@fortawesome/free-brands-svg-icons';
import {
faGlobe,
@ -15,16 +26,29 @@ import {
faEye,
faCodeBranch,
} from '@fortawesome/free-solid-svg-icons';
import Dev, {dummyDev as dev} from '../models/Dev';
// Typing
import IDev, {getDescription} from '../models/Dev';
import Experience from '../types/Experience';
import {getTimePeriod} from '../types/TimePeriod';
import Education from '../types/Education';
import Repo from '../types/Repo';
interface IProps {
dev: IDev;
}
/**
* Dev personal profile as seen by other people.
*/
const Profile: FC<Dev> = () => {
const Profile: FC<IProps> = ({dev}) => {
// display 404 page if dev is null
if (dev === null) {
return <NotFound />;
}
const fn = dev?.description;
console.log(fn);
/** return the icon corresponding to the social name */
const renderSocialIcon = (name: string): IconDefinition => {
switch (name) {
@ -38,88 +62,120 @@ const Profile: FC<Dev> = () => {
return faLinkedin;
case 'twitter':
return faTwitter;
case 'youtube':
return faYoutube;
default:
return faGlobe;
}
};
return (
return dev === undefined ? (
<div>Loading ... </div>
) : (
<section className="container">
<a href="profiles.html" className="btn">
<Link to={Routes.DEVELOPERS} className="btn">
Back to profiles
</a>
</Link>
<div className="profile-grid my-1">
<div className="profile-top bg-primary p-2">
<img
src="https://www.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50?s=200"
alt="Some guy"
src={dev.avatarUrl}
alt={dev.displayName}
className="round-img my-1"
/>
<h1 className="large">{dev.displayName}</h1>
<p className="lead">{dev.description}</p>
<p className="lead">{getDescription(dev.status, dev.company)}</p>
<p>{dev.location}</p>
<div className="icons my-1">
{Object.entries(dev.links).map(([icon, webAddress], i: number) => (
<a href={webAddress} key={i}>
<FontAwesomeIcon icon={renderSocialIcon(icon)} size="2x" />
</a>
))}
{Object.entries(dev.links)
.sort()
.map(([icon, webAddress], i: number) => (
<a
href={webAddress}
key={i}
target="_blank"
rel="noopener noreferrer"
>
<FontAwesomeIcon icon={renderSocialIcon(icon)} size="2x" />
</a>
))}
</div>
</div>
<div className="profile-about bg-light p-2">
<h2 className="text-primary">{`${dev.displayName}'s Bio`}</h2>
<p>{dev.bio}</p>
<p>
{dev.bio.length === 0
? 'Add a short bio to present yourself!'
: dev.bio}
</p>
<div className="line"></div>
<h2 className="text-primary">Skill Set</h2>
<div className="skills">
{dev.skills.map((s: string, i: number) => (
<div className="p-1" key={i}>
<FontAwesomeIcon icon={faCheck} /> {s}
</div>
))}
{dev.skills.length === 0
? 'Let us know about your skills!'
: dev.skills?.map((s: string, i: number) => (
<div className="p-1" key={i}>
<FontAwesomeIcon icon={faCheck} /> {s}
</div>
))}
</div>
</div>
<div className="profile-exp bg-white p-2">
<h2 className="text-primary">Experiences</h2>
{dev.experiences.map((exp: Experience, i: number) => (
<div key={i}>
<h3>{exp.company}</h3>
<p>{getTimePeriod(exp.from, exp.to)}</p>
<p>
<strong>Position: </strong>
{exp.position}
</p>
<p>
<strong>Description: </strong>
{exp.description}
</p>
{dev.experiences.length === 0 ? (
<div>
<img
src={require('../static/img/404.jpg')}
alt="no experiences"
/>
</div>
))}
) : (
dev.experiences.map((exp: Experience, i: number) => (
<div key={i}>
<h3>{exp.company}</h3>
<p>{getTimePeriod(exp.from, exp.to)}</p>
<p>
<strong>Position: </strong>
{exp.position}
</p>
<p>
<strong>Description: </strong>
{exp.description}
</p>
</div>
))
)}
</div>
<div className="profile-edu bg-white p-2">
<h2 className="text-primary">Education</h2>
{dev.educations.map((edu: Education, i: number) => (
<div key={i}>
<h3>{edu.school}</h3>
<p>{getTimePeriod(edu.from, edu.to)}</p>
<p>
<strong>Degree: </strong>
{edu.degree}
</p>
<p>
<strong>Field: </strong>
{edu.field}
</p>
<p>
<strong>Description: </strong>
{edu.description}
</p>
{dev.educations.length === 0 ? (
<div>
<img src={require('../static/img/404.jpg')} alt="no educations" />
</div>
))}
) : (
dev.educations.map((edu: Education, i: number) => (
<div key={i}>
<h3>{edu.school}</h3>
<p>{getTimePeriod(edu.from, edu.to)}</p>
<p>
<strong>Degree: </strong>
{edu.degree}
</p>
<p>
<strong>Field: </strong>
{edu.field}
</p>
<p>
<strong>Description: </strong>
{edu.description}
</p>
</div>
))
)}
</div>
<div className="profile-github">
@ -127,33 +183,58 @@ const Profile: FC<Dev> = () => {
<FontAwesomeIcon icon={faGithub} /> GitHub Repos
</h2>
{dev.repos.map((r: Repo, i: number) => (
<div className="repo bg-white my-1 p-1">
<div>
<h4>
<a href={r.link}>{r.name}</a>
</h4>
<p>{r.description}</p>
</div>
<div>
<ul>
<li className="badge badge-primary">
<FontAwesomeIcon icon={faStar} /> Stars: 42
</li>
<li className="badge badge-dark">
<FontAwesomeIcon icon={faEye} /> Watchers: 2
</li>
<li className="badge badge-light">
<FontAwesomeIcon icon={faCodeBranch} /> Forks: 4
</li>
</ul>
</div>
{dev.repos?.length === 0 ? (
<div>
<img
src={require('../static/img/404.jpg')}
alt="no repositories"
/>
</div>
))}
) : (
dev.repos.map((r: Repo, i: number) => (
<div className="repo bg-white my-1 p-1">
<div>
<h4>
<a href={r.link}>{r.name}</a>
</h4>
<p>{r.description}</p>
</div>
<div>
<ul>
<li className="badge badge-primary">
<FontAwesomeIcon icon={faStar} /> Stars: 42
</li>
<li className="badge badge-dark">
<FontAwesomeIcon icon={faEye} /> Watchers: 2
</li>
<li className="badge badge-light">
<FontAwesomeIcon icon={faCodeBranch} /> Forks: 4
</li>
</ul>
</div>
</div>
))
)}
</div>
</div>
</section>
);
};
export default Profile;
/**
* Container to fetch id params from thr URI and pass it to Profile page
*/
const ProfileContainer: FC = () => {
const {id} = useParams();
const Component = compose<FC>(
firestoreConnect(() => [`users/${id}`]),
connect(({firestore: {data}}: RootState) => ({
dev: data.users && data.users[id],
})),
)(Profile);
return <Component />;
};
export default ProfileContainer;

View file

@ -12,7 +12,7 @@ import Alert from '../components/Alert';
import Header from '../components/Header';
// Form
import useForm from '../hooks';
import Dev, {blankDev} from '../models/Dev';
import {Dev} from '../models/Dev';
// extends withFirebaseProps type to ad profile info
interface IProps extends Dev, WithFirebaseProps<User> {
@ -57,7 +57,7 @@ const SignUp: FC<IProps> = ({firebase, isEmpty, isLoaded, isActive}) => {
firebase
.createUser({email, password}, newUser(name, email))
.then(() => {
firebase.updateProfile(blankDev, {useSet: true, merge: true});
firebase.updateProfile(new Dev(), {useSet: true, merge: true});
resetForm();
})
.catch(err => setError(err));
@ -82,7 +82,7 @@ const SignUp: FC<IProps> = ({firebase, isEmpty, isLoaded, isActive}) => {
)
.then(() => {
if (!exists)
firebase.updateProfile(blankDev, {useSet: true, merge: true});
firebase.updateProfile(new Dev(), {useSet: true, merge: true});
});
})
.catch(err => setError(err));

View file

@ -22,7 +22,7 @@ const Router: FC = () => (
<Route exact path={Routes.SIGN_UP} component={SignUp} />
<Route exact path={Routes.SIGN_IN} component={SignIn} />
<Route exact path={Routes.DEVELOPERS} component={Developers} />
<Route exact path={Routes.PROFILE} component={Profile} />
<Route exact path={`${Routes.PROFILE}/:id`} component={Profile} />
<PrivateRoute exact path={Routes.EDIT_PROFILE} component={EditProfile} />
<PrivateRoute exact path={Routes.DASHBOARD} component={Dashboard} />
<PrivateRoute

View file

@ -1,7 +1,11 @@
// Redux
import {configureStore} from '@reduxjs/toolkit';
// Firebase
import {firebaseReducer, FirebaseReducer} from 'react-redux-firebase';
import {
firebaseReducer,
FirebaseReducer,
FirestoreReducer,
} from 'react-redux-firebase';
import {firestoreReducer} from 'redux-firestore';
// Typing
import {Schema} from './firebase/config';
@ -17,6 +21,7 @@ const store = configureStore({
// State type
export interface RootState {
firebase: FirebaseReducer.Reducer<Dev, Schema>;
firestore: FirestoreReducer.Reducer;
}
export default store;

View file

@ -4,6 +4,7 @@ interface IAlert {
text: string;
}
/** standard alert displaying form status after submission */
export const formAlert: IAlert = {
show: false,
color: 'danger',

View file

@ -8,4 +8,22 @@ interface Links {
youtube: string;
}
/**
* ensure link is formatted as http(s)//:...
* @param link URI to process
*/
export const parseLink = (link: string): string => {
if (link.slice(0, 4) === 'http') {
return link;
} else {
return `http://${link}`;
}
};
/**
* @param githubUsername
*/
export const getGithubLink = (githubUsername: string) =>
`https://github.com/${githubUsername}`;
export default Links;