Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add CandidateCard component and candidate details page #14

Merged
merged 6 commits into from
Feb 11, 2024
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"object-curly-spacing": ["error", "always"],
"semi": ["error", "never"],
"max-len": ["error", { "ignoreComments": true, "code": 120 }],
"react/prop-types": "off"
"react/prop-types": "off",
"react/display-name": "off"
}
}
33 changes: 26 additions & 7 deletions app/javascript/src/components/CandidateCard.jsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,32 @@
import React from 'react'
import { Card, Avatar, Flex, Typography } from 'antd'
import { GiftOutlined, MailOutlined, UserOutlined } from '@ant-design/icons'

const CandidateCard = ({ candidate }) => {
const { Title } = Typography

const CandidateCard = ({ candidate, loading }) => {
return (
<div className="card">
<div className="card-body">
<h5 className="card-title">{candidate.name}</h5>
<p className="card-text">{candidate.email}</p>
</div>
</div>
<Card loading={loading}>
<Flex gap={16}>
<Avatar size='large' icon={<UserOutlined />} />

<Flex gap={16} vertical>
<Title level={2}>{candidate.name}</Title>

<Flex gap={16}>
<MailOutlined style={{ fontSize: '20px' }} />
<a href={`mailto:${candidate.email}`} data-testid='email-link'>
{candidate.email}
</a>
</Flex>

<Flex gap={16}>
<GiftOutlined style={{ fontSize: '20px' }} />
<p>{candidate.birthdate?.format('YYYY/MM/DD')}</p>
</Flex>
</Flex>
</Flex>
</Card>
)
}

Expand Down
5 changes: 4 additions & 1 deletion app/javascript/src/i18n/en.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
{
"welcomeToHaistack": "Welcome to Haistack",
"candidates": "Candidates"
"candidates": "Candidates",
"candidate": {
"candidateDetails": "Candidate Details"
}
}
49 changes: 47 additions & 2 deletions app/javascript/src/main.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,48 @@
.main {
padding: 20px;
/* http://meyerweb.com/eric/tools/css/reset/
v2.0 | 20110126
License: none (public domain)
*/

html, body, div, span, applet, object, iframe,
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
a, abbr, acronym, address, big, cite, code,
del, dfn, em, img, ins, kbd, q, s, samp,
small, strike, strong, sub, sup, tt, var,
b, u, i, center,
dl, dt, dd, ol, ul, li,
fieldset, form, label, legend,
table, caption, tbody, tfoot, thead, tr, th, td,
article, aside, canvas, details, embed,
figure, figcaption, footer, header, hgroup,
menu, nav, output, ruby, section, summary,
time, mark, audio, video {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
font: inherit;
vertical-align: baseline;
}
/* HTML5 display-role reset for older browsers */
article, aside, details, figcaption, figure,
footer, header, hgroup, menu, nav, section {
display: block;
}
body {
line-height: 1;
}
ol, ul {
list-style: none;
}
blockquote, q {
quotes: none;
}
blockquote:before, blockquote:after,
q:before, q:after {
content: '';
content: none;
}
table {
border-collapse: collapse;
border-spacing: 0;
}
10 changes: 10 additions & 0 deletions app/javascript/src/repositories/candidates.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,13 @@ export const fetchCandidates = async () => {
meta: response.data?.meta,
}
}

export const getCandidate = async (id) => {
const response = await get(`${BASE_URL}/api/v1/candidates/${id}`)
const candidate = createCandidate(response.data)

return {
candidate,
status: response.status,
}
}
11 changes: 8 additions & 3 deletions app/javascript/src/routes.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import React from 'react'
import { createBrowserRouter } from 'react-router-dom'
import { Home, Candidates } from '@/views/index.js'
import { Home, Candidate } from '@/views/index.js'

export const router = createBrowserRouter([
{
path: '/',
element: <Home />,
},
{
path: '/candidates',
element: <Candidates />,
path: 'candidates',
children: [
{
path: ':id',
element: <Candidate />,
},
],
},
])
38 changes: 38 additions & 0 deletions app/javascript/src/tests/components/CandidateCard.spec.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { expect, describe, it, vi, afterEach } from 'vitest'
import { render, cleanup } from '@testing-library/react'
import CandidateCard from '@/components/CandidateCard.jsx'
import createCandidate from '@/models/candidate'

vi.mock('antd', () => ({
Card: ({ children }) => <div>{children}</div>,
Avatar: ({ children }) => <div>{children}</div>,
Flex: ({ children }) => <div>{children}</div>,
Typography: {
Title: ({ children }) => <div>{children}</div>,
},
}))

describe('CandidateCard', () => {
afterEach(cleanup)
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using cleanup because of vitest issue of not being automatically called upon vitest-dev/vitest#1430


const candidate = createCandidate({
id: 1,
name: 'John Doe',
email: 'john@doe.com',
birthdate: '1990-01-01',
})

it('renders a card with the candidate information', () => {
const { getByText } = render(<CandidateCard candidate={candidate} loading={false} />)

expect(getByText('John Doe')).toBeTruthy()
expect(getByText('john@doe.com')).toBeTruthy()
expect(getByText('1990/01/01')).toBeTruthy()
})

it('renders a link to send an email to the candidate', () => {
const { getByTestId } = render(<CandidateCard candidate={candidate} loading={false} />)

expect(getByTestId('email-link').getAttribute('href')).toBe('mailto:john@doe.com')
})
})
102 changes: 70 additions & 32 deletions app/javascript/src/tests/repositories/candidates.spec.js
Original file line number Diff line number Diff line change
@@ -1,59 +1,97 @@
import { describe, it, expect, vi } from 'vitest'
import { get } from '@/lib/request.js'
import { fetchCandidates } from '@/repositories/candidates.js'
import { fetchCandidates, getCandidate } from '@/repositories/candidates.js'

vi.mock('@/lib/request.js', () => ({
get: vi.fn(),
}))

vi.mock('@/models/candidate.js', () => ({ default: () => 'createCandidate' }))

describe('fetchCandidates', () => {
it('returns candidates and status', async () => {
const candidates = [{ id: 1, name: 'John Doe' }]
const response = { data: { candidates }, status: 200 }
get.mockResolvedValue(response)

const result = await fetchCandidates()

expect(result.candidates).toEqual(['createCandidate'])
expect(result.status).toBe(200)
})

describe('when response has meta', () => {
it('returns meta', async () => {
describe('candidates repository', () => {
describe('fetchCandidates', () => {
it('returns candidates and status', async () => {
const candidates = [{ id: 1, name: 'John Doe' }]
const meta = { total: 1 }
const response = { data: { candidates, meta }, status: 200 }
const response = { data: { candidates }, status: 200 }
get.mockResolvedValue(response)

const result = await fetchCandidates()

expect(result.meta).toEqual(meta)
expect(result.candidates).toEqual(['createCandidate'])
expect(result.status).toBe(200)
})
})

describe('when response has no candidates', () => {
it('returns empty array', async () => {
const response = { data: {}, status: 200 }
get.mockResolvedValue(response)
describe('when response has meta', () => {
it('returns meta', async () => {
const candidates = [{ id: 1, name: 'John Doe' }]
const meta = { total: 1 }
const response = { data: { candidates, meta }, status: 200 }
get.mockResolvedValue(response)

const result = await fetchCandidates()
const result = await fetchCandidates()

expect(result.candidates).toEqual([])
expect(result.meta).toEqual(meta)
})
})

describe('when response has no candidates', () => {
it('returns empty array', async () => {
const response = { data: {}, status: 200 }
get.mockResolvedValue(response)

const result = await fetchCandidates()

expect(result.candidates).toEqual([])
})
})

describe('when response is not successful', () => {
it('returns status', async () => {
const response = { data: {}, status: 500 }
get.mockResolvedValue(response)

const result = await fetchCandidates()

expect(result.candidates).toEqual([])
expect(result.status).toBe(500)
})
})
})

describe('when response is not successful', () => {
it('returns status', async () => {
const response = { data: {}, status: 500 }
describe('getCandidate', () => {
it('returns candidate and status', async () => {
const candidate = { id: 1, name: 'John Doe' }
const response = { data: candidate, status: 200 }
get.mockResolvedValue(response)

const result = await fetchCandidates()
const result = await getCandidate(1)

expect(result.candidate).toEqual('createCandidate')
expect(result.status).toBe(200)
})

describe('when response is not successful', () => {
it('returns status', async () => {
const response = { data: {}, status: 500 }
get.mockResolvedValue(response)

expect(result.candidates).toEqual([])
expect(result.status).toBe(500)
const result = await getCandidate(1)

expect(result.candidate).toEqual('createCandidate')
expect(result.status).toBe(500)
})
})

describe('when response has no data', () => {
it('returns status', async () => {
const response = { status: 404 }
get.mockResolvedValue(response)

const result = await getCandidate(1)

expect(result.candidate).toEqual('createCandidate')
expect(result.status).toBe(404)
})
})
})
})

52 changes: 52 additions & 0 deletions app/javascript/src/tests/views/Candidate.spec.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { expect, describe, it, vi, afterEach } from 'vitest'
import { render, cleanup } from '@testing-library/react'
import Candidate from '@/views/Candidate.jsx'
import { getCandidate } from '@/repositories/candidates.js'
import { useParams } from 'react-router-dom'

vi.mock('antd', () => ({
Typography: {
Title: ({ children }) => <div>{children}</div>,
},
}))

vi.mock('@/components/CandidateCard', () => ({
default: () => <div>CandidateCard</div>,
}))

vi.mock('react-router-dom', () => ({
useParams: vi.fn(() => ({ id: 1 })),
}))

vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key) => key }),
}))

vi.mock('@/repositories/candidates.js', () => ({
getCandidate: vi.fn(() =>({
candidate: {
id: 1,
name: 'John Doe',
email: 'john@doe.com',
birthdate: '1990-01-01',
},
})),
}))

describe('CandidateCard', () => {
afterEach(cleanup)

it('renders the title', () => {
const { getByText } = render(<Candidate />)

expect(getByText('candidate.candidateDetails')).toBeTruthy()
})

it('calls the getCandidate function with the id', () => {
useParams.mockReturnValue({ id: 2 })

render(<Candidate />)

expect(getCandidate).toHaveBeenCalledWith(2)
})
})
Loading
Loading