Skip to content

Commit

Permalink
feat(ui): add toast for status messages (#116)
Browse files Browse the repository at this point in the history
## Description

Add the ability to create toast messages using `addToast()` to write to
a common store

## Related Issue

- #115
  • Loading branch information
jeff-mccoy authored Jul 31, 2024
1 parent 98916d6 commit 983ae6a
Show file tree
Hide file tree
Showing 6 changed files with 263 additions and 0 deletions.
34 changes: 34 additions & 0 deletions ui/src/lib/features/toast/component.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<script lang="ts">
import { CheckmarkOutline, Close, Information, Warning } from 'carbon-icons-svelte'
import { removeToast, toast } from './store'
</script>

<div class="fixed top-20 right-5 p-4 z-40">
{#each $toast as toast}
<div
class="flex items-center justify-between shadow-gray-900 w-full max-w-xs p-4 text-gray-500 bg-white rounded-lg shadow-lg dark:text-gray-400 dark:bg-gray-800 mb-4"
>
<div class="flex items-center space-x-4">
<div class="flex-shrink-0 w-8 h-8 rounded-lg">
{#if toast.type === 'error'}
<Warning class="w-8 h-8 text-red-500" />
{:else if toast.type === 'warning'}
<Warning class="w-8 h-8 text-yellow-500" />
{:else if toast.type === 'info'}
<Information class="w-8 h-8 text-blue-500" />
{:else if toast.type === 'success'}
<CheckmarkOutline class="w-8 h-8 text-green-500" />
{/if}
</div>
<div class="text-sm font-normal">{toast.message}</div>
</div>
<button
type="button"
class="flex-shrink-0 bg-white text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex items-center justify-center h-8 w-8 dark:text-gray-500 dark:hover:text-white dark:bg-gray-800 dark:hover:bg-gray-700"
on:click={() => removeToast(toast.id)}
>
<Close class="w-5 h-5" />
</button>
</div>
{/each}
</div>
76 changes: 76 additions & 0 deletions ui/src/lib/features/toast/component.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: 2024-Present The UDS Authors

import { fireEvent, render, screen } from '@testing-library/svelte'
import { get } from 'svelte/store'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ToastComponent from './component.svelte'
import { addToast, toast } from './store'

// Mock the icon components
vi.mock('carbon-icons-svelte', () => {
const mockComponent = () => ({
$$: {
on_mount: [],
on_destroy: [],
before_update: [],
after_update: [],
},
})

return {
CheckmarkOutline: vi.fn().mockImplementation(mockComponent),
Close: vi.fn().mockImplementation(mockComponent),
Information: vi.fn().mockImplementation(mockComponent),
Warning: vi.fn().mockImplementation(mockComponent),
}
})

describe('Toast Component', () => {
beforeEach(() => {
// Reset the store before each test
toast.set([])
})

it('renders nothing when there are no toasts', () => {
const { container } = render(ToastComponent)
expect(container.firstChild?.childNodes.length).toBe(0)
})

it('renders a toast message', () => {
addToast({ message: 'Test toast', timeoutSecs: 3, type: 'info' })
render(ToastComponent)
expect(screen.getByText('Test toast')).toBeInTheDocument()
})

it('renders multiple toast messages', () => {
addToast({ message: 'Toast 1', timeoutSecs: 3, type: 'info' })
addToast({ message: 'Toast 2', timeoutSecs: 3, type: 'success' })
render(ToastComponent)
expect(screen.getByText('Toast 1')).toBeInTheDocument()
expect(screen.getByText('Toast 2')).toBeInTheDocument()
})

it('renders the correct number of icons for each toast type', () => {
addToast({ message: 'Error toast', timeoutSecs: 3, type: 'error' })
addToast({ message: 'Warning toast', timeoutSecs: 3, type: 'warning' })
addToast({ message: 'Info toast', timeoutSecs: 3, type: 'info' })
addToast({ message: 'Success toast', timeoutSecs: 3, type: 'success' })
const { container } = render(ToastComponent)

const icons = container.querySelectorAll('.w-8.h-8')
// 4 toasts * 2 icons per toast (icon + close button)
expect(icons.length).toBe(8)
})

it('removes a toast when the close button is clicked', async () => {
addToast({ message: 'Test toast', timeoutSecs: 3, type: 'info' })
render(ToastComponent)

const closeButton = screen.getByRole('button')
await fireEvent.click(closeButton)

expect(get(toast)).toHaveLength(0)
expect(screen.queryByText('Test toast')).not.toBeInTheDocument()
})
})
5 changes: 5 additions & 0 deletions ui/src/lib/features/toast/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: 2024-Present The UDS Authors

export { default as ToastPanel } from './component.svelte'
export { addToast } from './store'
117 changes: 117 additions & 0 deletions ui/src/lib/features/toast/store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: 2024-Present The UDS Authors

import { get } from 'svelte/store'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { addToast, removeToast, toast, type Toast } from './store'

describe('Toast Store', () => {
beforeEach(() => {
// Reset the store before each test
toast.set([])
})

afterEach(() => {
// Clear all timers after each test
vi.useRealTimers()
})

it('should initialize with an empty array', () => {
expect(get(toast)).toEqual([])
})

it('should add a toast to the store', () => {
const newToast: Toast = {
message: 'Test toast',
timeoutSecs: 3,
type: 'info',
}
addToast(newToast)
const toasts = get(toast)
expect(toasts).toHaveLength(1)
expect(toasts[0]).toMatchObject(newToast)
expect(toasts[0].id).toBeDefined()
})

it('should remove a toast from the store', () => {
const newToast: Toast = {
message: 'Test toast',
timeoutSecs: 3,
type: 'info',
}
addToast(newToast)
const toasts = get(toast)
const toastId = toasts[0].id

removeToast(toastId)
expect(get(toast)).toHaveLength(0)
})

it('should automatically remove a toast after the specified timeout', () => {
vi.useFakeTimers()
const newToast: Toast = {
message: 'Test toast',
timeoutSecs: 3,
type: 'info',
}
addToast(newToast)
expect(get(toast)).toHaveLength(1)

vi.advanceTimersByTime(3000)
expect(get(toast)).toHaveLength(0)
})

it('should not remove a toast if timeout is not specified', () => {
vi.useFakeTimers()
const newToast: Toast = {
message: 'Test toast',
timeoutSecs: 0,
type: 'info',
}
addToast(newToast)
expect(get(toast)).toHaveLength(1)

vi.advanceTimersByTime(10000)
expect(get(toast)).toHaveLength(1)
})

it('should add multiple toasts and maintain their order', () => {
const toast1: Toast = { message: 'Toast 1', timeoutSecs: 3, type: 'info' }
const toast2: Toast = { message: 'Toast 2', timeoutSecs: 3, type: 'success' }
const toast3: Toast = { message: 'Toast 3', timeoutSecs: 3, type: 'error' }

addToast(toast1)
addToast(toast2)
addToast(toast3)

const toasts = get(toast)
expect(toasts).toHaveLength(3)
expect(toasts[0].message).toBe('Toast 1')
expect(toasts[1].message).toBe('Toast 2')
expect(toasts[2].message).toBe('Toast 3')
})

it('should not remove other toasts when removing a specific toast', () => {
const toast1: Toast = { message: 'Toast 1', timeoutSecs: 3, type: 'info' }
const toast2: Toast = { message: 'Toast 2', timeoutSecs: 3, type: 'success' }

addToast(toast1)
addToast(toast2)

const toasts = get(toast)
removeToast(toasts[0].id)

const updatedToasts = get(toast)
expect(updatedToasts).toHaveLength(1)
expect(updatedToasts[0].message).toBe('Toast 2')
})

it('should do nothing when trying to remove a non-existent toast', () => {
const toast1: Toast = { message: 'Toast 1', timeoutSecs: 3, type: 'info' }
addToast(toast1)

removeToast(12345) // Non-existent ID

expect(get(toast)).toHaveLength(1)
})
})
29 changes: 29 additions & 0 deletions ui/src/lib/features/toast/store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: 2024-Present The UDS Authors

import { writable } from 'svelte/store'

export type Toast = {
id?: number
message: string
timeoutSecs: number
type: 'success' | 'info' | 'warning' | 'error'
}

export const toast = writable<Toast[]>([])

export const addToast = (newToast: Toast) => {
toast.update((toasts) => {
const id = Date.now() + Math.random()
const toast = { id, ...newToast }

if (toast.timeoutSecs) {
setTimeout(() => removeToast(id), toast.timeoutSecs * 1000)
}
return [...toasts, toast]
})
}

export const removeToast = (id?: number) => {
toast.update((toasts) => toasts.filter((toast) => toast.id !== id))
}
2 changes: 2 additions & 0 deletions ui/src/routes/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import { Breadcrumb, isSidebarExpanded, Navbar, Sidebar } from '$features/navigation'
import '../app.postcss'
import { ToastPanel } from '$features/toast'
onMount(initFlowbite)
afterNavigate(initFlowbite)
Expand All @@ -29,6 +30,7 @@
<Breadcrumb />
</div>
<div class="flex-grow overflow-hidden p-4 pt-0">
<ToastPanel />
<slot />
</div>
</main>

0 comments on commit 983ae6a

Please sign in to comment.