feat: design applications and first api calls
Co-authored-by: Walid <87608619+WalidKorchi@users.noreply.github.com>
This commit is contained in:
12
components/design/Divider/Divider.stories.tsx
Normal file
12
components/design/Divider/Divider.stories.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import { Meta, Story } from '@storybook/react'
|
||||
|
||||
import { Divider as Component } from './'
|
||||
|
||||
const Stories: Meta = {
|
||||
title: 'Divider',
|
||||
component: Component
|
||||
}
|
||||
|
||||
export default Stories
|
||||
|
||||
export const Divider: Story = (arguments_) => <Component {...arguments_} />
|
10
components/design/Divider/Divider.test.tsx
Normal file
10
components/design/Divider/Divider.test.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { render } from '@testing-library/react'
|
||||
|
||||
import { Divider } from './'
|
||||
|
||||
describe('<Divider />', () => {
|
||||
it('should render successfully', () => {
|
||||
const { baseElement } = render(<Divider />)
|
||||
expect(baseElement).toBeTruthy()
|
||||
})
|
||||
})
|
7
components/design/Divider/Divider.tsx
Normal file
7
components/design/Divider/Divider.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
export const Divider: React.FC = () => {
|
||||
return (
|
||||
<div className='relative flex justify-center h-[2px] w-full mb-3'>
|
||||
<div className='absolute h-[2px] w-8/12 bg-gray-600 dark:bg-white/20 rounded-full'></div>
|
||||
</div>
|
||||
)
|
||||
}
|
1
components/design/Divider/index.ts
Normal file
1
components/design/Divider/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './Divider'
|
34
components/design/FormState/FormState.test.tsx
Normal file
34
components/design/FormState/FormState.test.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { render } from '@testing-library/react'
|
||||
|
||||
import { FormState } from './'
|
||||
|
||||
describe('<FormState />', () => {
|
||||
it('should return nothing if the state is idle', async () => {
|
||||
const { container } = render(<FormState state='idle' />)
|
||||
expect(container.innerHTML.length).toEqual(0)
|
||||
})
|
||||
|
||||
it('should return nothing if the message is null', async () => {
|
||||
const { container } = render(<FormState state='error' />)
|
||||
expect(container.innerHTML.length).toEqual(0)
|
||||
})
|
||||
|
||||
it('should render the <Loader /> if state is loading', async () => {
|
||||
const { getByTestId } = render(<FormState state='loading' />)
|
||||
expect(getByTestId('loader')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the success message if state is success', async () => {
|
||||
const message = 'Success Message'
|
||||
const { getByText } = render(
|
||||
<FormState state='success' message={message} />
|
||||
)
|
||||
expect(getByText(message)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the error message if state is error', async () => {
|
||||
const message = 'Error Message'
|
||||
const { getByText } = render(<FormState state='error' message={message} />)
|
||||
expect(getByText(message)).toBeInTheDocument()
|
||||
})
|
||||
})
|
56
components/design/FormState/FormState.tsx
Normal file
56
components/design/FormState/FormState.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import classNames from 'classnames'
|
||||
import useTranslation from 'next-translate/useTranslation'
|
||||
|
||||
import { FormState as FormStateType } from 'hooks/useFormState'
|
||||
import { Loader } from '../Loader'
|
||||
|
||||
export interface FormStateProps {
|
||||
state: FormStateType
|
||||
message?: string | null
|
||||
id?: string
|
||||
}
|
||||
|
||||
export const FormState: React.FC<FormStateProps> = (props) => {
|
||||
const { state, message, id } = props
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (state === 'loading') {
|
||||
return (
|
||||
<div data-testid='loader' className='mt-8 flex justify-center'>
|
||||
<Loader />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (state === 'idle' || message == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={classNames(
|
||||
'mt-6 relative flex flex-row items-center font-medium text-center max-w-xl',
|
||||
{
|
||||
'text-red-800 dark:text-red-400': state === 'error',
|
||||
'text-green-800 dark:text-green-400': state === 'success'
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className='thumbnail bg-cover absolute top-0 inline-block font-headline'></div>
|
||||
<span id={id} className={classNames({ 'pl-6': state === 'error' })}>
|
||||
<b>{t(`errors:${state}`)}:</b> {message}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
.thumbnail {
|
||||
min-width: 20px;
|
||||
width: 20px;
|
||||
height: ${state === 'error' ? '20px' : '25px'};
|
||||
background-image: url('/images/svg/icons/input/${state}.svg');
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
)
|
||||
}
|
1
components/design/FormState/index.ts
Normal file
1
components/design/FormState/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './FormState'
|
15
components/design/IconButton/IconButton.test.tsx
Normal file
15
components/design/IconButton/IconButton.test.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import { CogIcon } from '@heroicons/react/solid'
|
||||
|
||||
import { IconButton } from './IconButton'
|
||||
|
||||
describe('<IconButton />', () => {
|
||||
it('should render successfully', () => {
|
||||
const { baseElement } = render(
|
||||
<IconButton className='h-10 w-10'>
|
||||
<CogIcon />
|
||||
</IconButton>
|
||||
)
|
||||
expect(baseElement).toBeTruthy()
|
||||
})
|
||||
})
|
20
components/design/IconButton/IconButton.tsx
Normal file
20
components/design/IconButton/IconButton.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import classNames from 'classnames'
|
||||
|
||||
export interface IconButtonProps
|
||||
extends React.ComponentPropsWithoutRef<'button'> {}
|
||||
|
||||
export const IconButton: React.FC<IconButtonProps> = (props) => {
|
||||
const { children, className, ...rest } = props
|
||||
|
||||
return (
|
||||
<button
|
||||
className={classNames(
|
||||
'text-center flex items-center justify-center text-green-800 dark:text-green-400 focus:outline-none focus:animate-pulse hover:animate-pulse',
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
1
components/design/IconButton/index.ts
Normal file
1
components/design/IconButton/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './IconButton'
|
10
components/design/IconLink/IconLink.test.tsx
Normal file
10
components/design/IconLink/IconLink.test.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { render } from '@testing-library/react'
|
||||
|
||||
import { IconLink } from './IconLink'
|
||||
|
||||
describe('<IconLink />', () => {
|
||||
it('should render successfully', () => {
|
||||
const { baseElement } = render(<IconLink href='' />)
|
||||
expect(baseElement).toBeTruthy()
|
||||
})
|
||||
})
|
32
components/design/IconLink/IconLink.tsx
Normal file
32
components/design/IconLink/IconLink.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import Link from 'next/link'
|
||||
import classNames from 'classnames'
|
||||
|
||||
export interface IconLinkProps {
|
||||
selected?: boolean
|
||||
href: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
export const IconLink: React.FC<IconLinkProps> = (props) => {
|
||||
const { children, selected, href, title } = props
|
||||
|
||||
return (
|
||||
<div className='w-full flex justify-center group'>
|
||||
<Link href={href}>
|
||||
<a className='w-full flex justify-center relative group' title={title}>
|
||||
{children}
|
||||
<div className='absolute flex items-center w-3 h-12 left-0'>
|
||||
<span
|
||||
className={classNames(
|
||||
'absolute w-4/12 bg-green-700 rounded-r-lg group-hover:h-5',
|
||||
{
|
||||
'h-full': selected
|
||||
}
|
||||
)}
|
||||
></span>
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
1
components/design/IconLink/index.ts
Normal file
1
components/design/IconLink/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './IconLink'
|
30
components/design/Input/Input.stories.tsx
Normal file
30
components/design/Input/Input.stories.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import { Meta, Story } from '@storybook/react'
|
||||
|
||||
import { Input, InputProps } from './Input'
|
||||
import { AuthenticationForm } from '../../Authentication/AuthenticationForm'
|
||||
|
||||
const Stories: Meta = {
|
||||
title: 'Input',
|
||||
component: Input
|
||||
}
|
||||
|
||||
export default Stories
|
||||
|
||||
const Template: Story<InputProps> = (arguments_) => (
|
||||
<AuthenticationForm>
|
||||
<Input {...arguments_} />
|
||||
</AuthenticationForm>
|
||||
)
|
||||
|
||||
export const Text = Template.bind({})
|
||||
Text.args = { label: 'Text', name: 'text', type: 'text' }
|
||||
|
||||
export const Password = Template.bind({})
|
||||
Password.args = { label: 'Password', name: 'password', type: 'password' }
|
||||
|
||||
export const Error = Template.bind({})
|
||||
Error.args = {
|
||||
label: 'Error',
|
||||
type: 'text',
|
||||
error: 'Oops, this field is required 🙈.'
|
||||
}
|
52
components/design/Input/Input.test.tsx
Normal file
52
components/design/Input/Input.test.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import { render, fireEvent } from '@testing-library/react'
|
||||
|
||||
import { Input, getInputType } from './'
|
||||
|
||||
describe('<Input />', () => {
|
||||
it('should render the label', async () => {
|
||||
const labelContent = 'label content'
|
||||
const { getByText } = render(<Input label={labelContent} />)
|
||||
expect(getByText(labelContent)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render forgot password link', async () => {
|
||||
const { queryByTestId } = render(
|
||||
<Input type='text' label='content' showForgotPassword />
|
||||
)
|
||||
const forgotPasswordLink = queryByTestId('forgot-password-link')
|
||||
expect(forgotPasswordLink).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render forgot password link', async () => {
|
||||
const { queryByTestId } = render(
|
||||
<Input type='password' label='content' showForgotPassword />
|
||||
)
|
||||
const forgotPasswordLink = queryByTestId('forgot-password-link')
|
||||
expect(forgotPasswordLink).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render the eye icon if the input is not of type "password"', async () => {
|
||||
const { queryByTestId } = render(<Input type='text' label='content' />)
|
||||
const passwordEye = queryByTestId('password-eye')
|
||||
expect(passwordEye).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handlePassword with eye icon', async () => {
|
||||
const { findByTestId } = render(<Input type='password' label='content' />)
|
||||
const passwordEye = await findByTestId('password-eye')
|
||||
const input = await findByTestId('input')
|
||||
expect(input).toHaveAttribute('type', 'password')
|
||||
fireEvent.click(passwordEye)
|
||||
expect(input).toHaveAttribute('type', 'text')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getInputType', () => {
|
||||
it('should return `text`', async () => {
|
||||
expect(getInputType('password')).toEqual('text')
|
||||
})
|
||||
|
||||
it('should return `password`', async () => {
|
||||
expect(getInputType('text')).toEqual('password')
|
||||
})
|
||||
})
|
90
components/design/Input/Input.tsx
Normal file
90
components/design/Input/Input.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import useTranslation from 'next-translate/useTranslation'
|
||||
import { FormState } from '../FormState'
|
||||
|
||||
export interface InputProps extends React.ComponentPropsWithRef<'input'> {
|
||||
label: string
|
||||
error?: string | null
|
||||
showForgotPassword?: boolean
|
||||
}
|
||||
|
||||
export const getInputType = (inputType: string): string => {
|
||||
return inputType === 'password' ? 'text' : 'password'
|
||||
}
|
||||
|
||||
export const Input: React.FC<InputProps> = (props) => {
|
||||
const {
|
||||
label,
|
||||
name,
|
||||
type = 'text',
|
||||
showForgotPassword = false,
|
||||
error,
|
||||
...rest
|
||||
} = props
|
||||
const { t } = useTranslation()
|
||||
const [inputType, setInputType] = useState(type)
|
||||
|
||||
const handlePassword = (): void => {
|
||||
const oppositeType = getInputType(inputType)
|
||||
setInputType(oppositeType)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='flex flex-col'>
|
||||
<div className='flex justify-between mt-6 mb-2'>
|
||||
<label className='pl-1' htmlFor={name}>
|
||||
{label}
|
||||
</label>
|
||||
{type === 'password' && showForgotPassword ? (
|
||||
<Link href='/authentication/forgot-password'>
|
||||
<a
|
||||
className='font-headline text-center text-xs sm:text-sm text-green-800 dark:text-green-400 hover:underline'
|
||||
data-testid='forgot-password-link'
|
||||
>
|
||||
{t('authentication:forgot-password')}
|
||||
</a>
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
<div className='mt-0 relative'>
|
||||
<input
|
||||
data-testid='input'
|
||||
data-cy={`input-${name ?? 'name'}`}
|
||||
className='h-11 leading-10 px-3 rounded-lg bg-[#f1f1f1] text-[#2a2a2a] caret-green-600 font-paragraph w-full focus:border focus:outline-none focus:shadow-green border-0'
|
||||
{...rest}
|
||||
id={name}
|
||||
name={name}
|
||||
type={inputType}
|
||||
/>
|
||||
{type === 'password' && (
|
||||
<div
|
||||
data-testid='password-eye'
|
||||
onClick={handlePassword}
|
||||
className='password-eye absolute cursor-pointer bg-cover bg-[#f1f1f1]'
|
||||
/>
|
||||
)}
|
||||
<FormState
|
||||
id={`error-${name ?? 'input'}`}
|
||||
state={error == null ? 'idle' : 'error'}
|
||||
message={error}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style jsx>
|
||||
{`
|
||||
.password-eye {
|
||||
top: 12px;
|
||||
right: 16px;
|
||||
z-index: 1;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background-image: url('/images/svg/icons/input/${inputType}.svg');
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
</>
|
||||
)
|
||||
}
|
1
components/design/Input/index.ts
Normal file
1
components/design/Input/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './Input'
|
14
components/design/Loader/Loader.stories.tsx
Normal file
14
components/design/Loader/Loader.stories.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import { Meta, Story } from '@storybook/react'
|
||||
|
||||
import { Loader as Component, LoaderProps } from './Loader'
|
||||
|
||||
const Stories: Meta = {
|
||||
title: 'Loader',
|
||||
component: Component
|
||||
}
|
||||
|
||||
export default Stories
|
||||
|
||||
export const Loader: Story<LoaderProps> = (arguments_) => (
|
||||
<Component {...arguments_} />
|
||||
)
|
20
components/design/Loader/Loader.test.tsx
Normal file
20
components/design/Loader/Loader.test.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { render } from '@testing-library/react'
|
||||
|
||||
import { Loader } from './'
|
||||
|
||||
describe('<Loader />', () => {
|
||||
it('should render with correct width and height', async () => {
|
||||
const size = 20
|
||||
const { findByTestId } = render(<Loader width={size} height={size} />)
|
||||
const progressSpinner = await findByTestId('progress-spinner')
|
||||
expect(progressSpinner).toHaveStyle(`width: ${size}px`)
|
||||
expect(progressSpinner).toHaveStyle(`height: ${size}px`)
|
||||
})
|
||||
|
||||
it('should render with default width and height', async () => {
|
||||
const { findByTestId } = render(<Loader />)
|
||||
const progressSpinner = await findByTestId('progress-spinner')
|
||||
expect(progressSpinner).toHaveStyle('width: 50px')
|
||||
expect(progressSpinner).toHaveStyle('height: 50px')
|
||||
})
|
||||
})
|
81
components/design/Loader/Loader.tsx
Normal file
81
components/design/Loader/Loader.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
export interface LoaderProps {
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
|
||||
export const Loader: React.FC<LoaderProps> = (props) => {
|
||||
const { width = 50, height = 50 } = props
|
||||
|
||||
return (
|
||||
<>
|
||||
<div data-testid='progress-spinner' className='progress-spinner'>
|
||||
<svg className='progress-spinner-svg' viewBox='25 25 50 50'>
|
||||
<circle
|
||||
className='progress-spinner-circle'
|
||||
cx='50'
|
||||
cy='50'
|
||||
r='20'
|
||||
fill='none'
|
||||
strokeWidth='2'
|
||||
strokeMiterlimit='10'
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<style jsx>
|
||||
{`
|
||||
.progress-spinner {
|
||||
position: relative;
|
||||
margin: 0 auto;
|
||||
width: ${width}px;
|
||||
height: ${height}px;
|
||||
display: inline-block;
|
||||
}
|
||||
.progress-spinner::before {
|
||||
content: '';
|
||||
display: block;
|
||||
padding-top: 100%;
|
||||
}
|
||||
.progress-spinner-svg {
|
||||
animation: progress-spinner-rotate 2s linear infinite;
|
||||
height: 100%;
|
||||
transform-origin: center center;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: auto;
|
||||
}
|
||||
.progress-spinner-circle {
|
||||
stroke-dasharray: 89, 200;
|
||||
stroke-dashoffset: 0;
|
||||
stroke: #27b05e;
|
||||
animation: progress-spinner-dash 1.5s ease-in-out infinite;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
@keyframes progress-spinner-rotate {
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@keyframes progress-spinner-dash {
|
||||
0% {
|
||||
stroke-dasharray: 1, 200;
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
50% {
|
||||
stroke-dasharray: 89, 200;
|
||||
stroke-dashoffset: -35px;
|
||||
}
|
||||
100% {
|
||||
stroke-dasharray: 89, 200;
|
||||
stroke-dashoffset: -124px;
|
||||
}
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
</>
|
||||
)
|
||||
}
|
1
components/design/Loader/index.ts
Normal file
1
components/design/Loader/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './Loader'
|
Reference in New Issue
Block a user