Skip to content

Commit

Permalink
Merge pull request #636 from systemli/Add-Notification-Provider
Browse files Browse the repository at this point in the history
✨ Add Notification Provider
  • Loading branch information
0x46616c6b authored May 22, 2024
2 parents 7593d6b + a5cbff6 commit 6efcb73
Show file tree
Hide file tree
Showing 13 changed files with 235 additions and 34 deletions.
10 changes: 10 additions & 0 deletions src/App.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { render, screen } from '@testing-library/react'
import App from './App'

describe('App', () => {
it('should render', () => {
render(<App />)

expect(screen.getByText('Ticker Login')).toBeInTheDocument()
})
})
41 changes: 23 additions & 18 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
import { FC } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { FC } from 'react'
import { BrowserRouter, Route, Routes } from 'react-router-dom'
import '../leaflet.config.js'
import Notification from './components/Notification.js'
import ProtectedRoute from './components/ProtectedRoute'
import { AuthProvider } from './contexts/AuthContext'
import { FeatureProvider } from './contexts/FeatureContext'
import { NotificationProvider } from './contexts/NotificationContext.js'
import ThemeProvider from './theme/ThemeProvider'
import HomeView from './views/HomeView'
import LoginView from './views/LoginView'
import NotFoundView from './views/NotFoundView'
import SettingsView from './views/SettingsView'
import TickerView from './views/TickerView'
import UsersView from './views/UsersView'
import ProtectedRoute from './components/ProtectedRoute'
import NotFoundView from './views/NotFoundView'
import { FeatureProvider } from './contexts/FeatureContext'
import ThemeProvider from './theme/ThemeProvider'
import '../leaflet.config.js'

const App: FC = () => {
const queryClient = new QueryClient({
Expand All @@ -23,18 +25,21 @@ const App: FC = () => {
<ThemeProvider>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AuthProvider>
<FeatureProvider>
<Routes>
<Route element={<ProtectedRoute outlet={<HomeView />} role="user" />} path="/" />
<Route element={<ProtectedRoute outlet={<TickerView />} role="user" />} path="/ticker/:tickerId" />
<Route element={<ProtectedRoute outlet={<UsersView />} role="admin" />} path="/users" />
<Route element={<ProtectedRoute outlet={<SettingsView />} role="admin" />} path="/settings" />
<Route element={<LoginView />} path="/login" />
<Route element={<NotFoundView />} path="*" />
</Routes>
</FeatureProvider>
</AuthProvider>
<NotificationProvider>
<AuthProvider>
<FeatureProvider>
<Routes>
<Route element={<ProtectedRoute outlet={<HomeView />} role="user" />} path="/" />
<Route element={<ProtectedRoute outlet={<TickerView />} role="user" />} path="/ticker/:tickerId" />
<Route element={<ProtectedRoute outlet={<UsersView />} role="admin" />} path="/users" />
<Route element={<ProtectedRoute outlet={<SettingsView />} role="admin" />} path="/settings" />
<Route element={<LoginView />} path="/login" />
<Route element={<NotFoundView />} path="*" />
</Routes>
</FeatureProvider>
</AuthProvider>
<Notification />
</NotificationProvider>
</BrowserRouter>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
Expand Down
36 changes: 36 additions & 0 deletions src/components/Notification.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { render, screen } from '@testing-library/react'
import { NotificationProvider } from '../contexts/NotificationContext'
import Notification from './Notification'

describe('Notification', () => {
function setup() {
return render(
<NotificationProvider>
<Notification />
</NotificationProvider>
)
}

beforeEach(() => {
vi.clearAllMocks()
})

it('should render empty notification', () => {
setup()
})

it('should render a notification', () => {
vi.mock('../contexts/useNotification', () => ({
__esModule: true,
default: () => ({
isOpen: true,
notification: { content: 'Hello, World!' },
closeNotification: vi.fn(),
}),
}))

setup()

expect(screen.getByText('Hello, World!')).toBeInTheDocument()
})
})
25 changes: 25 additions & 0 deletions src/components/Notification.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Alert, Snackbar } from '@mui/material'
import { FC } from 'react'
import useNotification from '../contexts/useNotification'

interface Props {
autoHideDuration?: number | null
}

const Notification: FC<Props> = ({ autoHideDuration = 6000 }) => {
const { notification, isOpen, closeNotification } = useNotification()

if (isOpen && notification?.content) {
return (
<Snackbar anchorOrigin={{ vertical: 'top', horizontal: 'right' }} autoHideDuration={autoHideDuration} onClose={closeNotification} open={isOpen}>
<Alert onClose={closeNotification} severity={notification.severity || 'info'}>
{notification.content}
</Alert>
</Snackbar>
)
}

return null
}

export default Notification
7 changes: 5 additions & 2 deletions src/components/navigation/UserDropdown.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import React, { FC, useCallback, useState } from 'react'
import { AccountCircle } from '@mui/icons-material'
import { IconButton, Menu, MenuItem } from '@mui/material'
import React, { FC, useCallback, useState } from 'react'
import useAuth from '../../contexts/useAuth'
import useNotification from '../../contexts/useNotification'
import UserChangePasswordModalForm from '../user/UserChangePasswordModalForm'

const UserDropdown: FC = () => {
const [formModalOpen, setFormModalOpen] = useState<boolean>(false)
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
const { user, logout } = useAuth()
const { createNotification } = useNotification()

const handleMenu = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget)
Expand All @@ -19,7 +21,8 @@ const UserDropdown: FC = () => {

const handleLogout = useCallback(() => {
logout()
}, [logout])
createNotification({ content: 'You have been logged out' })
}, [createNotification, logout])

return (
<>
Expand Down
29 changes: 29 additions & 0 deletions src/contexts/NotificationContext.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { render, screen } from '@testing-library/react'
import NotificationContext, { NotificationProvider } from './NotificationContext'

describe('NotificationContext', () => {
beforeEach(() => {
vi.clearAllMocks()
})

function setup() {
return render(
<NotificationProvider>
<NotificationContext.Consumer>
{value => (
<div>
{value?.notification?.content}
{value?.isOpen?.toString()}
</div>
)}
</NotificationContext.Consumer>
</NotificationProvider>
)
}

it('should render', async () => {
setup()

expect(await screen.findByText('false')).toBeInTheDocument()
})
})
45 changes: 45 additions & 0 deletions src/contexts/NotificationContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { ReactNode, createContext, useMemo, useState } from 'react'

type Severity = 'success' | 'error' | 'warning' | 'info'

export interface Notification {
content: ReactNode
severity?: Severity
}

interface NotificationContextType {
notification?: Notification
isOpen?: boolean
createNotification: (notification: Notification) => void
closeNotification: () => void
}

const NotificationContext = createContext<NotificationContextType | undefined>(undefined)

export function NotificationProvider({ children }: Readonly<{ children: ReactNode }>): JSX.Element {
const [notification, setNotification] = useState<Notification | undefined>(undefined)
const [isOpen, setIsOpen] = useState<boolean>(false)

const createNotification = (notification: Notification): void => {
setNotification(notification)
setIsOpen(true)
}

const closeNotification = (): void => {
setIsOpen(false)
}

const contextValue = useMemo(
() => ({
notification,
isOpen,
createNotification,
closeNotification,
}),
[notification, isOpen]
)

return <NotificationContext.Provider value={contextValue}>{children}</NotificationContext.Provider>
}

export default NotificationContext
24 changes: 24 additions & 0 deletions src/contexts/useNotification.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { renderHook } from '@testing-library/react-hooks'
import { ReactNode } from 'react'
import { NotificationProvider } from './NotificationContext'
import useNotification from './useNotification'

describe('useNotification', () => {
it('throws error when not rendered within NotificationProvider', () => {
const { result } = renderHook(() => useNotification())

expect(result.error).toEqual(Error('useNotification must be used within a NotificationProvider'))
})

it('returns context when rendered within NotificationProvider', async () => {
const wrapper = ({ children }: { children: ReactNode }) => <NotificationProvider>{children}</NotificationProvider>
const { result } = renderHook(() => useNotification(), { wrapper })

expect(result.current).toEqual({
isOpen: false,
notification: undefined,
closeNotification: expect.any(Function),
createNotification: expect.any(Function),
})
})
})
12 changes: 12 additions & 0 deletions src/contexts/useNotification.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { useContext } from 'react'
import NotificationContext from './NotificationContext'

const useNotification = () => {
const context = useContext(NotificationContext)
if (!context) {
throw new Error('useNotification must be used within a NotificationProvider')
}
return context
}

export default useNotification
9 changes: 6 additions & 3 deletions src/views/HomeView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { render, screen } from '@testing-library/react'
import sign from 'jwt-encode'
import { MemoryRouter } from 'react-router'
import { AuthProvider } from '../contexts/AuthContext'
import { NotificationProvider } from '../contexts/NotificationContext'
import HomeView from './HomeView'

describe('HomeView', () => {
Expand All @@ -18,9 +19,11 @@ describe('HomeView', () => {
return render(
<QueryClientProvider client={client}>
<MemoryRouter>
<AuthProvider>
<HomeView />
</AuthProvider>
<NotificationProvider>
<AuthProvider>
<HomeView />
</AuthProvider>
</NotificationProvider>
</MemoryRouter>
</QueryClientProvider>
)
Expand Down
9 changes: 6 additions & 3 deletions src/views/SettingsView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import sign from 'jwt-encode'
import { MemoryRouter } from 'react-router'
import { vi } from 'vitest'
import { AuthProvider } from '../contexts/AuthContext'
import { NotificationProvider } from '../contexts/NotificationContext'
import SettingsView from './SettingsView'

describe('SettingsView', function () {
Expand Down Expand Up @@ -52,9 +53,11 @@ describe('SettingsView', function () {
return render(
<QueryClientProvider client={client}>
<MemoryRouter>
<AuthProvider>
<SettingsView />
</AuthProvider>
<NotificationProvider>
<AuthProvider>
<SettingsView />
</AuthProvider>
</NotificationProvider>
</MemoryRouter>
</QueryClientProvider>
)
Expand Down
13 changes: 8 additions & 5 deletions src/views/TickerView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { MemoryRouter, Route, Routes } from 'react-router'
import { vi } from 'vitest'
import ProtectedRoute from '../components/ProtectedRoute'
import { AuthProvider } from '../contexts/AuthContext'
import { NotificationProvider } from '../contexts/NotificationContext'
import TickerView from './TickerView'

describe('TickerView', function () {
Expand All @@ -19,11 +20,13 @@ describe('TickerView', function () {
return render(
<QueryClientProvider client={client}>
<MemoryRouter initialEntries={['/ticker/1']}>
<AuthProvider>
<Routes>
<Route element={<ProtectedRoute outlet={<TickerView />} role="user" />} path="/ticker/:tickerId" />
</Routes>
</AuthProvider>
<NotificationProvider>
<AuthProvider>
<Routes>
<Route element={<ProtectedRoute outlet={<TickerView />} role="user" />} path="/ticker/:tickerId" />
</Routes>
</AuthProvider>
</NotificationProvider>
</MemoryRouter>
</QueryClientProvider>
)
Expand Down
9 changes: 6 additions & 3 deletions src/views/UsersView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import sign from 'jwt-encode'
import { MemoryRouter } from 'react-router'
import { vi } from 'vitest'
import { AuthProvider } from '../contexts/AuthContext'
import { NotificationProvider } from '../contexts/NotificationContext'
import UsersView from './UsersView'

describe('UsersView', function () {
Expand All @@ -26,9 +27,11 @@ describe('UsersView', function () {
return render(
<QueryClientProvider client={client}>
<MemoryRouter>
<AuthProvider>
<UsersView />
</AuthProvider>
<NotificationProvider>
<AuthProvider>
<UsersView />
</AuthProvider>
</NotificationProvider>
</MemoryRouter>
</QueryClientProvider>
)
Expand Down

0 comments on commit 6efcb73

Please sign in to comment.