From 03ee3751ec69a62a1ac7f5e7c571719a763bdc2b Mon Sep 17 00:00:00 2001 From: yatanvesh Date: Sun, 29 Oct 2023 00:14:35 +0530 Subject: [PATCH] feat: add loading states with swr --- frontend/package.json | 1 + frontend/src/app/layout.tsx | 4 +- .../src/components/dashboard/data-card.tsx | 10 +- .../[projectId]/applications-list.tsx | 160 +++++++++++------- .../[projectId]/project-analytics.tsx | 36 ++-- frontend/src/components/toast-provider.tsx | 30 ++++ frontend/src/stores/toast-store.ts | 79 +++++++++ .../[projectId]/applications-list.spec.tsx | 5 + .../components/dashboard/data-card.spec.tsx | 15 ++ .../tests/components/toast-provider.spec.tsx | 81 +++++++++ frontend/tests/stores/toast-store.spec.tsx | 115 +++++++++++++ frontend/tests/test-utils.tsx | 5 +- pnpm-lock.yaml | 18 +- 13 files changed, 474 insertions(+), 85 deletions(-) create mode 100644 frontend/src/components/toast-provider.tsx create mode 100644 frontend/src/stores/toast-store.ts create mode 100644 frontend/tests/components/toast-provider.spec.tsx create mode 100644 frontend/tests/stores/toast-store.spec.tsx diff --git a/frontend/package.json b/frontend/package.json index ea8e83d0..7999f6bd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -29,6 +29,7 @@ "react-icons": "^4.11.0", "react-intl": "^6.5.1", "react-tailwindcss-datepicker": "^1.6.6", + "swr": "^2.2.4", "zustand": "^4.4.4" }, "devDependencies": { diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 1626c283..e1b98477 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -3,6 +3,8 @@ import '@/styles/globals.scss'; import { Inter } from 'next/font/google'; import { AbstractIntlMessages, NextIntlClientProvider } from 'next-intl'; +import { ToastProvider } from '@/components/toast-provider'; + const inter = Inter({ subsets: ['latin'] }); const supportedLocales = ['en']; @@ -31,7 +33,7 @@ export default async function RootLayout({ - {children} + {children} diff --git a/frontend/src/components/dashboard/data-card.tsx b/frontend/src/components/dashboard/data-card.tsx index e33ad84a..f986325e 100644 --- a/frontend/src/components/dashboard/data-card.tsx +++ b/frontend/src/components/dashboard/data-card.tsx @@ -7,6 +7,7 @@ export interface DataCardProps { totalValue: string | number; currentValue?: string; percentage?: string; + loading?: boolean; } export function DataCard({ @@ -15,6 +16,7 @@ export function DataCard({ totalValue, currentValue, percentage, + loading, }: DataCardProps) { return (
@@ -24,7 +26,13 @@ export function DataCard({ data-testid={`data-card-total-value-${metric}`} className="text-3xl font-semibold text-neutral-content" > - {totalValue} + {!loading && totalValue} + {loading && ( + + )} {currentValue && percentage && (

diff --git a/frontend/src/components/projects/[projectId]/applications-list.tsx b/frontend/src/components/projects/[projectId]/applications-list.tsx index e6d6b529..72ead0ed 100644 --- a/frontend/src/components/projects/[projectId]/applications-list.tsx +++ b/frontend/src/components/projects/[projectId]/applications-list.tsx @@ -1,97 +1,127 @@ import Link from 'next/link'; import { useTranslations } from 'next-intl'; -import { useEffect } from 'react'; import { PencilFill, Plus } from 'react-bootstrap-icons'; +import useSWR from 'swr'; import { handleRetrieveApplications, handleRetrievePromptConfigs } from '@/api'; import { Navigation } from '@/constants'; +import { ApiError } from '@/errors'; import { useApplications, usePromptConfig, useSetProjectApplications, useSetPromptConfig, } from '@/stores/project-store'; +import { useShowError } from '@/stores/toast-store'; import { populateApplicationId, populateProjectId } from '@/utils/navigation'; export function ApplicationsList({ projectId }: { projectId: string }) { const t = useTranslations('projectOverview'); - const setProjectApplications = useSetProjectApplications(); const applications = useApplications(projectId); - const setPromptConfig = useSetPromptConfig(); + const setProjectApplications = useSetProjectApplications(); + const promptConfigs = usePromptConfig(); + const setPromptConfig = useSetPromptConfig(); - async function fetchApplications() { - const applicationsRes = await handleRetrieveApplications(projectId); - setProjectApplications(projectId, applicationsRes); + const showError = useShowError(); - const promptConfigs = await Promise.all( - applicationsRes.map((application) => - handleRetrievePromptConfigs({ - projectId, - applicationId: application.id, - }), + const { isLoading } = useSWR(projectId, handleRetrieveApplications, { + onSuccess(data) { + setProjectApplications(projectId, data); + }, + onError({ message }: ApiError) { + showError(message); + }, + }); + + useSWR( + () => applications, + (applications) => + Promise.all( + applications.map((application) => + handleRetrievePromptConfigs({ + projectId, + applicationId: application.id, + }), + ), ), + { + onSuccess(data) { + data.forEach((promptConfig, index) => { + setPromptConfig(applications![index].id, promptConfig); + }); + }, + }, + ); + + function renderTable() { + if (isLoading && !applications?.length) { + return ( +

+ +
+ ); + } + if (!applications?.length) { + return null; + } + + return ( + + + + + + + + + + {applications.map(({ name, id }) => { + const applicationUrl = populateApplicationId( + populateProjectId( + Navigation.Applications, + projectId, + ), + id, + ); + return ( + + + + + + ); + })} + +
{t('name')}{t('configs')}{t('edit')}
+ + {name} + + + {promptConfigs[id]?.length} + + + + +
); - promptConfigs.forEach((promptConfig, index) => { - setPromptConfig(applicationsRes[index].id, promptConfig); - }); } - useEffect(() => { - void fetchApplications(); - }, []); - return (

{t('applications')}

-
- - - - - - - - - - {applications?.map(({ name, id }) => { - const applicationUrl = populateApplicationId( - populateProjectId( - Navigation.Applications, - projectId, - ), - id, - ); - return ( - - - - - - ); - })} - -
{t('name')}{t('configs')}{t('edit')}
- - {name} - - - {promptConfigs[id]?.length} - - - - -
- diff --git a/frontend/src/components/projects/[projectId]/project-analytics.tsx b/frontend/src/components/projects/[projectId]/project-analytics.tsx index 9592c7dd..047c5e95 100644 --- a/frontend/src/components/projects/[projectId]/project-analytics.tsx +++ b/frontend/src/components/projects/[projectId]/project-analytics.tsx @@ -1,18 +1,20 @@ import { useTranslations } from 'next-intl'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { Activity, Cash } from 'react-bootstrap-icons'; import { DateValueType } from 'react-tailwindcss-datepicker'; +import useSWR from 'swr'; import { handleProjectAnalytics } from '@/api'; import { DataCard } from '@/components/dashboard/data-card'; import { DatePicker } from '@/components/dashboard/date-picker'; +import { ApiError } from '@/errors'; +import { useShowError } from '@/stores/toast-store'; import { useDateFormat } from '@/stores/user-config-store'; -import { ProjectAnalytics } from '@/types'; export function ProjectAnalytics({ projectId }: { projectId: string }) { const t = useTranslations('projectOverview'); const dateFormat = useDateFormat(); - + const invokeErrorToast = useShowError(); const oneWeekAgo = new Date(); oneWeekAgo.setDate(oneWeekAgo.getDate() - 7); @@ -20,19 +22,19 @@ export function ProjectAnalytics({ projectId }: { projectId: string }) { startDate: oneWeekAgo, endDate: new Date(), }); - - const [analytics, setAnalytics] = useState(null); - - useEffect(() => { - (async () => { - const applicationAnalytics = await handleProjectAnalytics({ - projectId, - fromDate: dateRange?.startDate, - toDate: dateRange?.endDate, - }); - setAnalytics(applicationAnalytics); - })(); - }, [dateRange]); + const { data: analytics, isLoading } = useSWR( + { + projectId, + fromDate: dateRange?.startDate, + toDate: dateRange?.endDate, + }, + handleProjectAnalytics, + { + onError({ message }: ApiError) { + invokeErrorToast(message); + }, + }, + ); return (
@@ -55,6 +57,7 @@ export function ProjectAnalytics({ projectId }: { projectId: string }) { totalValue={analytics?.totalAPICalls ?? ''} percentage={'100'} currentValue={'324'} + loading={isLoading} />
diff --git a/frontend/src/components/toast-provider.tsx b/frontend/src/components/toast-provider.tsx new file mode 100644 index 00000000..f74247a1 --- /dev/null +++ b/frontend/src/components/toast-provider.tsx @@ -0,0 +1,30 @@ +'use client'; + +import { useToasts } from '@/stores/toast-store'; + +export function ToastProvider({ children }: { children: React.ReactNode }) { + return ( + <> + {children} + + + ); +} + +function Toast() { + const toasts = useToasts(); + + return ( +
+ {toasts.map(({ type, message }, index) => ( +
+ {message} +
+ ))} +
+ ); +} diff --git a/frontend/src/stores/toast-store.ts b/frontend/src/stores/toast-store.ts new file mode 100644 index 00000000..a35a8b9d --- /dev/null +++ b/frontend/src/stores/toast-store.ts @@ -0,0 +1,79 @@ +import { create } from 'zustand'; +import { StateCreator } from 'zustand/vanilla'; + +export enum ToastType { + INFO = 'alert-info', + SUCCESS = 'alert-success', + ERROR = 'alert-error', + WARNING = 'alert-warning', +} + +const DEFAULT_TIMEOUT = 4000; + +interface ToastMessage { + message: string; + type: ToastType; +} + +export interface ToastStore { + toasts: ToastMessage[]; + addToast: (toast: ToastMessage) => void; + addErrorToast: (message: string) => void; + addSuccessToast: (message: string) => void; + addInfoToast: (message: string) => void; + addWarningToast: (message: string) => void; + popToast: () => void; + timeout: number; +} + +export const useToastStoreStateCreator: StateCreator = ( + set, + get, +) => ({ + toasts: [], + addToast: (toast: ToastMessage) => { + set((state) => ({ + toasts: [...state.toasts, toast], + })); + setTimeout(() => { + get().popToast(); + }, get().timeout); + }, + addErrorToast: (message: string) => { + get().addToast({ + message, + type: ToastType.ERROR, + }); + }, + addSuccessToast: (message: string) => { + get().addToast({ + message, + type: ToastType.SUCCESS, + }); + }, + addInfoToast: (message: string) => { + get().addToast({ + message, + type: ToastType.INFO, + }); + }, + addWarningToast: (message: string) => { + get().addToast({ + message, + type: ToastType.WARNING, + }); + }, + popToast: () => { + set((state) => ({ + toasts: state.toasts.slice(1), + })); + }, + timeout: DEFAULT_TIMEOUT, +}); + +export const useToastStore = create(useToastStoreStateCreator); +export const useToasts = () => useToastStore((s) => s.toasts); +export const useShowError = () => useToastStore((s) => s.addErrorToast); +export const useShowSuccess = () => useToastStore((s) => s.addSuccessToast); +export const useShowInfo = () => useToastStore((s) => s.addInfoToast); +export const useShowWarning = () => useToastStore((s) => s.addWarningToast); diff --git a/frontend/tests/app/projects/[projectId]/applications-list.spec.tsx b/frontend/tests/app/projects/[projectId]/applications-list.spec.tsx index 5af61904..c587717e 100644 --- a/frontend/tests/app/projects/[projectId]/applications-list.spec.tsx +++ b/frontend/tests/app/projects/[projectId]/applications-list.spec.tsx @@ -1,6 +1,7 @@ import { waitFor } from '@testing-library/react'; import { ApplicationFactory, PromptConfigFactory } from 'tests/factories'; import { render, screen } from 'tests/test-utils'; +import { beforeEach } from 'vitest'; import * as ApplicationAPI from '@/api/applications-api'; import * as PromptConfigAPI from '@/api/prompt-config-api'; @@ -21,6 +22,10 @@ describe('ApplicationsList', () => { 'handleRetrieveApplications', ); + beforeEach(() => { + vi.clearAllMocks(); + }); + it('renders application list', async () => { const applications = ApplicationFactory.batchSync(2); handleRetrieveApplicationsSpy.mockResolvedValueOnce(applications); diff --git a/frontend/tests/components/dashboard/data-card.spec.tsx b/frontend/tests/components/dashboard/data-card.spec.tsx index b1e112fd..6984ae8f 100644 --- a/frontend/tests/components/dashboard/data-card.spec.tsx +++ b/frontend/tests/components/dashboard/data-card.spec.tsx @@ -63,4 +63,19 @@ describe('Data Card tests', () => { const totalValue = screen.getByText('32k'); expect(totalValue).toBeInTheDocument(); }); + it('renders loading state', () => { + render( + } + metric={'Models Cost'} + totalValue={'32k'} + currentValue={'16000'} + percentage={'100%'} + loading={true} + />, + ); + + const loader = screen.getByTestId('data-card-loader'); + expect(loader).toBeInTheDocument(); + }); }); diff --git a/frontend/tests/components/toast-provider.spec.tsx b/frontend/tests/components/toast-provider.spec.tsx new file mode 100644 index 00000000..7a7fe785 --- /dev/null +++ b/frontend/tests/components/toast-provider.spec.tsx @@ -0,0 +1,81 @@ +import { render, renderHook, screen } from 'tests/test-utils'; + +import { ToastProvider } from '@/components/toast-provider'; +import { useShowInfo } from '@/stores/toast-store'; + +describe('ToastProvider', () => { + vi.useFakeTimers(); + it('renders toast container', () => { + render( + +

Screen

+
, + ); + + const toastContainer = screen.getByTestId('toast-container'); + expect(toastContainer).toBeInTheDocument(); + }); + + it('renders a toast and pops it', () => { + const { rerender } = render( + +

Screen

+
, + ); + + const { + result: { current: showInfo }, + } = renderHook(useShowInfo); + showInfo('Test message'); + rerender( + +

Screen

+
, + ); + + const toastMessage = screen.getByTestId('toast-message'); + expect(toastMessage).toBeInTheDocument(); + + vi.runAllTimers(); + rerender( + +

Screen

+
, + ); + + const toastMessageQuery = screen.queryByTestId('toast-message'); + expect(toastMessageQuery).not.toBeInTheDocument(); + }); + + it('renders multiple timers and pops them', () => { + const { rerender } = render( + +

Screen

+
, + ); + + const { + result: { current: showInfo }, + } = renderHook(useShowInfo); + showInfo('Test message'); + showInfo('Test message2'); + rerender( + +

Screen

+
, + ); + + const toastMessages = screen.getAllByTestId('toast-message'); + expect(toastMessages.length).toBe(2); + + vi.runAllTimers(); + rerender( + +

Screen

+
, + ); + + const toastMessagesQuery = screen.queryAllByTestId('toast-message'); + expect(toastMessagesQuery.length).toBe(0); + }); +}); diff --git a/frontend/tests/stores/toast-store.spec.tsx b/frontend/tests/stores/toast-store.spec.tsx new file mode 100644 index 00000000..ec87c2e3 --- /dev/null +++ b/frontend/tests/stores/toast-store.spec.tsx @@ -0,0 +1,115 @@ +import { renderHook } from 'tests/test-utils'; +import { beforeEach } from 'vitest'; + +import { + ToastType, + useShowError, + useShowInfo, + useShowSuccess, + useShowWarning, + useToasts, +} from '@/stores/toast-store'; + +describe('toast-store tests', () => { + vi.useFakeTimers(); + + beforeEach(() => { + vi.runAllTimers(); + }); + + it('adds toast and pops it after a timeout', () => { + const { + result: { current: showError }, + } = renderHook(useShowError); + const message = 'err message'; + showError(message); + + const { + result: { current: toasts }, + } = renderHook(useToasts); + expect(toasts).toStrictEqual([{ message, type: ToastType.ERROR }]); + + vi.runAllTimers(); + + const { + result: { current: toastsNow }, + } = renderHook(useToasts); + expect(toastsNow).toStrictEqual([]); + }); + + it('adds multiple toasts and pops them after a timeout', () => { + const { + result: { current: showError }, + } = renderHook(useShowError); + const { + result: { current: showSuccess }, + } = renderHook(useShowSuccess); + const message = 'message'; + showError(message); + showSuccess(message); + + const { + result: { current: toasts }, + } = renderHook(useToasts); + expect(toasts.length).toBe(2); + + vi.runAllTimers(); + + const { + result: { current: toastsNow }, + } = renderHook(useToasts); + expect(toastsNow).toStrictEqual([]); + }); + + it('adds error variation of toast', () => { + const { + result: { current: showError }, + } = renderHook(useShowError); + const message = 'message'; + showError(message); + + const { + result: { current: toasts }, + } = renderHook(useToasts); + expect(toasts[0].type).toBe(ToastType.ERROR); + }); + + it('adds success variation of toast', () => { + const { + result: { current: showSuccess }, + } = renderHook(useShowSuccess); + const message = 'message'; + showSuccess(message); + + const { + result: { current: toasts }, + } = renderHook(useToasts); + expect(toasts[0].type).toBe(ToastType.SUCCESS); + }); + + it('adds info variation of toast', () => { + const { + result: { current: showInfo }, + } = renderHook(useShowInfo); + const message = 'message'; + showInfo(message); + + const { + result: { current: toasts }, + } = renderHook(useToasts); + expect(toasts[0].type).toBe(ToastType.INFO); + }); + + it('adds warning variation of toast', () => { + const { + result: { current: showWarning }, + } = renderHook(useShowWarning); + const message = 'message'; + showWarning(message); + + const { + result: { current: toasts }, + } = renderHook(useToasts); + expect(toasts[0].type).toBe(ToastType.WARNING); + }); +}); diff --git a/frontend/tests/test-utils.tsx b/frontend/tests/test-utils.tsx index 7792aea0..b4e5486a 100644 --- a/frontend/tests/test-utils.tsx +++ b/frontend/tests/test-utils.tsx @@ -8,6 +8,7 @@ import { import { RouterContext } from 'next/dist/shared/lib/router-context.shared-runtime'; import { NextIntlClientProvider } from 'next-intl'; import locales from 'public/locales/en.json'; +import { SWRConfig } from 'swr'; import { nextRouterMock } from 'tests/mocks'; const customRender = ( @@ -19,7 +20,9 @@ const customRender = ( return ( - {children} + new Map() }}> + {children} + ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 88eb2400..acd1f6f6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -175,6 +175,9 @@ importers: react-tailwindcss-datepicker: specifier: ^1.6.6 version: 1.6.6(dayjs@1.11.10)(react@18.2.0) + swr: + specifier: ^2.2.4 + version: 2.2.4(react@18.2.0) zustand: specifier: ^4.4.4 version: 4.4.4(@types/react@18.2.33)(react@18.2.0) @@ -13667,7 +13670,7 @@ packages: } engines: { node: '>= 10.13.0' } dependencies: - '@types/node': 18.18.7 + '@types/node': 20.8.9 merge-stream: 2.0.0 supports-color: 8.1.1 dev: true @@ -18251,6 +18254,19 @@ packages: webpack: 5.89.0(@swc/core@1.3.95)(esbuild@0.18.20)(webpack-cli@5.1.4) dev: true + /swr@2.2.4(react@18.2.0): + resolution: + { + integrity: sha512-njiZ/4RiIhoOlAaLYDqwz5qH/KZXVilRLvomrx83HjzCWTfa+InyfAjv05PSFxnmLzZkNO9ZfvgoqzAaEI4sGQ==, + } + peerDependencies: + react: ^16.11.0 || ^17.0.0 || ^18.0.0 + dependencies: + client-only: 0.0.1 + react: 18.2.0 + use-sync-external-store: 1.2.0(react@18.2.0) + dev: false + /symbol-tree@3.2.4: resolution: {