Problem
I build this silly app to get the commenters’ information on some PR, mostly for learning how to use API’s and for improving my JS dom skills.
Although it is working, it’s mostly spaghetti code and very disorganized, so I am sure there is a lot of room for improvement.
I would like to get some feedback on how to better organize my code and structure it so it can easily grow and can be more maintainable.
This is the JS file (probably need to split into modules/classes, but don’t know where to start):
function createElement(type, options = {}) {
const element = document.createElement(type)
Object.keys(options).forEach(option => {
element[option] = options[option]
})
return element
}
function createUser(user) {
const userHtml = {
avatarUrl: createElement('img', {
src: user.avatar_url,
}),
login: createElement('a', {
href: user.html_url,
textContent: user.login,
}),
}
if (user.name) {
const name = createElement('div', {
textContent: user.name,
})
userHtml.name = name
userHtml.name.classList.add('name')
}
userHtml.avatarUrl.classList.add('avatar')
userHtml.login.classList.add('login')
return userHtml
}
function renderPullRequestInfo(pullRequest) {
const { login } = createUser(pullRequest.user)
const title = createElement('a', {
id: 'title',
href: pullRequest.html_url,
textContent: pullRequest.title,
})
document.querySelector('#pull-request-info').append(title, login)
}
function renderUsers(users) {
const fragment = document.createDocumentFragment()
const title = document.createElement('h3')
if (users.length === 0) {
title.textContent = 'No reviewers found :(.'
} else {
title.textContent = 'Reviewers:'
users.forEach(user => {
if (user.name) {
const li = createElement('li')
const { avatarUrl, login, name } = createUser(user)
li.append(avatarUrl, login, name)
fragment.appendChild(li)
}
})
const ul = document.querySelector('#user-list')
ul.before(title)
ul.appendChild(fragment)
}
}
async function fetchGithubApi(endpoint) {
const githubApiUrl = 'https://api.github.com'
const response = await fetch(
endpoint.startsWith(githubApiUrl) ? endpoint : `${githubApiUrl}${endpoint}`,
{
headers: { Accept: 'application/vnd.github.v3+json' },
}
)
const data = await response.json()
return data
}
async function getPullRequestData(event) {
event.preventDefault()
document.querySelector('#pull-request-url-messages').textContent = ''
document.querySelector('#user-list').innerHTML = ''
document.querySelector('#pull-request-info').innerHTML = ''
try {
const pullRequestUrl = new URL(
document.querySelector('#pull-request-url').value
)
const pullRequestPath = `/repos${pullRequestUrl.pathname.replace(
//pull//,
'/pulls/'
)}`
const pullRequest = await fetchGithubApi(pullRequestPath)
renderPullRequestInfo(pullRequest)
if (pullRequest.state === 'open') {
const allComments = await Promise.all([
fetchGithubApi(pullRequest.review_comments_url),
fetchGithubApi(pullRequest.comments_url),
])
const uniqueUsersUrls = [
...new Set(
allComments
.flat()
.filter(comment => comment.user.login !== pullRequest.user.login)
.map(comment => comment.user.url)
),
]
const users = await Promise.all(
uniqueUsersUrls.map(uniqueUserUrl => fetchGithubApi(uniqueUserUrl))
)
renderUsers(users)
} else {
document.querySelector('#pull-request-url-messages').textContent =
'The pull request is already closed!'
}
} catch (e) {
document.querySelector('#pull-request-url-messages').textContent = e
}
}
document
.querySelector('#pull-request-form')
.addEventListener('submit', getPullRequestData)
And this is the index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Pull Request Reviewers</title>
</head>
<body>
<header class="container">
<h1>Pull Request Reviewers</h1>
</header>
<main class="container">
<form id="pull-request-form">
<input
id="pull-request-url"
type="text"
placeholder="Enter the full pull request url here"
/>
<button>Go!</button>
<div id="pull-request-url-messages"></div>
</form>
<div id="pull-request-info"></div>
<ul id="user-list"></ul>
</main>
<script src="index.js"></script>
</body>
</html>
Solution
For a framework-less vanilla JS/DOM it’s pretty much fine as is.
I can only suggest a few minor/cosmetic points to consider:
- extract things from the big DOM event handler so it contains only the major flow and rename it from
getPullRequestData
to something else because your function also renders the data butgetXXX
implies only the action of getting – I would use a generic name likeonsubmit
since the function isn’t reusable and this name tells exactly what it actually is. - shorten some names because verbosity can make simple things look complicated
- sometimes
then
+catch
may convey the flow better thanawait
+ try/catch
function onsubmit(event) {
event.preventDefault()
byId('user-list').textContent = ''
byId('pull-request-info').textContent = ''
renderStatus('')
fetchPR()
.then(async pr => {
renderPrInfo(pr)
renderPrBody(pr.state === 'open' && await fetchUsers(pr))
})
.catch(renderStatus)
}
- since you’re not writing a low-level library, there should be no need to use createDocumentFragment explicitly, instead just accumulate the elements in an array and pass it to
append
, which will allow us to express the intent and flow more explicitly:
function renderUsers(users) {
const ul = byId('user-list')
const title = createElement('h3', {
textContent: users.length
? 'Reviewers:'
: 'No reviewers found :(.',
})
ul.before(title)
ul.append(...users.map(renderUser).filter(Boolean))
}
- use
createElement
‘s second props parameter more increateUser
- add a third parameter
children
tocreateElement
- use
Object.assign
increateElement
- make
createUser
return an array so it can be used directly inappend
- shorten
.map(item => singleArgFunction(item))
to.map(singleArgFunction)
- add JSDoc comments describing what the function does and its params/returned value, some IDE provide convenient plugins or built-in functionality to simplify the task
- see if you like declaring functions in the order that loosely follows the execution flow from more generalized to more specific
const apiUrl = 'https://api.github.com'
const apiOptions = {
headers: {
Accept: 'application/vnd.github.v3+json',
},
}
byId('pull-request-form').addEventListener('submit', onsubmit)
function onsubmit(event) {
event.preventDefault()
byId('user-list').textContent = ''
byId('pull-request-info').textContent = ''
renderStatus('')
fetchPR()
.then(async pr => {
renderPrInfo(pr)
renderPrBody(pr.state === 'open' && await fetchUsers(pr))
})
.catch(renderStatus)
}
function fetchPR() {
const { pathname } = new URL(byId('pull-request-url').value)
const url = `/repos${pathname.replace('/pull/', '/pulls/')}`
return fetchGithubApi(url)
}
async function fetchUsers(pr) {
const allComments = await Promise.all([
fetchGithubApi(pr.review_comments_url),
fetchGithubApi(pr.comments_url),
])
const userUrls = allComments
.flat()
.filter(({ user }) => user.login !== pr.user.login)
.map(({ user }) => user.url)
const uniqUrls = [...new Set(userUrls)]
return Promise.all(uniqUrls.map(fetchGithubApi))
}
async function fetchGithubApi(endpoint) {
const url = endpoint.startsWith(apiUrl) ? endpoint : `${apiUrl}${endpoint}`
return (await fetch(url, apiOptions)).json()
}
function renderStatus(status) {
byId('pull-request-url-messages').textContent = status
}
function renderPrInfo(pr) {
const title = createElement('a', {
id: 'title',
href: pr.html_url,
textContent: pr.title,
})
byId('pull-request-info').append(title, createAvatar(pr.user))
}
function renderPrBody(users) {
if (users) {
renderUsers(users)
} else {
renderStatus('The pull request is already closed!')
}
}
function renderUsers(users) {
const ul = byId('user-list')
const title = createElement('h3', {
textContent: users.length
? 'Reviewers:'
: 'No reviewers found :(.',
})
ul.before(title)
ul.append(...users.map(renderUser).filter(Boolean))
}
function renderUser(user) {
return user.name && createElement('li', {}, createUser(user))
}
function createUser(user) {
return [
createAvatar(user),
createElement('a', {
href: user.html_url,
className: 'login',
textContent: user.login,
}),
user.name &&
createElement('div', {
className: 'name',
textContent: user.name,
}),
].filter(Boolean)
}
function createAvatar(user) {
return createElement('img', {
src: user.avatar_url,
className: 'avatar',
})
}
function createElement(type, options = {}, children) {
const element = document.createElement(type)
Object.assign(element, options)
if (children) element.append(...children)
return element
}
function byId(elementId) {
return document.getElementById(elementId)
}