From 6b01fffd46cf11621525c044e38a75fe5f3e31b2 Mon Sep 17 00:00:00 2001 From: Vinit khandal <111434418+vinit717@users.noreply.github.com> Date: Thu, 1 Aug 2024 00:37:20 +0530 Subject: [PATCH 1/4] Fix mutiple api calls for user urls api (#128) * fix: mutiple api calls for user urls api * remove retry from useGetUrlsQuery * add refetch func to query client * fix: export of query client --- src/pages/_app.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 38d26d6e..757267a2 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -11,7 +11,14 @@ interface MyAppProps { pageProps: AppProps; } -export const queryClient = new QueryClient({ defaultOptions: { queries: { retry: 3 } } }); +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 3, + refetchOnWindowFocus: false, + }, + }, +}); export default function MyApp({ Component, pageProps }: MyAppProps) { return ( From f84f348bdaed7f6348cdc28d7f2c6cc58704ed6a Mon Sep 17 00:00:00 2001 From: Sunny Sahsi Date: Mon, 12 Aug 2024 06:01:41 +0530 Subject: [PATCH 2/4] fix: url redirection (#133) --- src/pages/[redirect]/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/[redirect]/index.tsx b/src/pages/[redirect]/index.tsx index 414c007c..90f43beb 100644 --- a/src/pages/[redirect]/index.tsx +++ b/src/pages/[redirect]/index.tsx @@ -20,7 +20,7 @@ const Redirect = () => { }); useEffect(() => { - if (shortUrlCode && data?.url.originalUrl) { + if (shortUrlCode && `${TINY_API_URL}/redirect/${shortUrlCode}`) { startTimer(); } }, [timer, data]); From 20994ee1ca6b9f26f34c648d4bafc019bba3876c Mon Sep 17 00:00:00 2001 From: Vinit khandal <111434418+vinit717@users.noreply.github.com> Date: Fri, 30 Aug 2024 01:23:25 +0530 Subject: [PATCH 3/4] New UI (#131) * ui: fix home page heading and input box * fix url output section and qr modal * ui: fix modal style and icons * make reset the input when modal close * style: add rds icon to qr code and fix modal * chore: undo changes * fix qr code file import * add flow bite library * Revert "add flow bite library" This reverts commit 4f1f09754b8d2fb9928e46e4459348199ee37b94. * Revert "fix qr code file import" This reverts commit 3d6032540f76133b536ccc49ddd25058efbf0591. * Revert "chore: undo changes" This reverts commit 1dc1d08ac0ad6420a9aad8749f3ce911fa4e83fa. * style: add background gradient * style: change font family to space mono * fix: login modal to use modal * style: add custom blue color * style: fix ui of navbar home dahboard and user profile * test: revert changes * test: revert changes * fix modal and add share functionality * style: fix modals spacing * style: fix home page font sixe and shorten btn * style: fix dashboard url and creation time * fix: redirect page and remove create new icon * style: fix ui for mobile * fix user dashboard url list * style: fix url list item * style: chnage minor UI fixes * style: fix bg gradient and mobile menu bar * style: fix redirect page and home page text for mobile * style: fix outsection modal for mobile * chore: fix error for wrong url * style: fix dashboard page and redirect page shimmer * chore: fix minor styling * chore: remove toast from inputsection and add custom error * style: add animation for errormessage and signout btn * ui: fix animation for signout btn * chore: refactor the code structure and UI * chore: fix userprofile shimmer * chore: refactor terniary with variable * chore: remove redundant code and fix component strucuture * chore: remove gradient from tailwind config and use relative path for rds logo * chore: remove . from rds logo path * chore: refactor url validation function * chore: chnage rds logo size * Test/fix for new UI (#135) * test: refactor input/output section tests * test: fix for navbar failing tests * test: fix for input section component * test: fix output section tests and write additional test for this * test: fix for url list item * test: remove create new button component test * test: fix login modal * test: remove qr code component * test: fix for profile icon * test: fix toast component * test: fix for redirect page * test: fix for format date utility function * test: fix for validate url * test: add for format url * test: fix for app component * test: test for dashboard page * chore: refactor app components * style: remove unnecessary css and convert px to rem * chore: break profile icon component * chore: break url list component * chore: refactor the navbar component * chore: run lint * chore: break input ssction component * chore: add spinner for short url loading state * chore: fix failing test and add more additional tests * chore: fix failing tests * chore: improve test coverage of app components * chore: remove gradient from tailwind config and use relative path for rds logo * chore: fix failing test --------- Co-authored-by: Sunny Sahsi --- .../components/App/InputSection.test.tsx | 32 --- .../components/App/OutputSection.test.tsx | 141 +++++++++--- .../components/App/SortenUrlForm.test.tsx | 134 +++++++++++ __tests__/components/CreateNew.test.tsx | 22 -- .../Dashhboard/UrlListItem.test.tsx | 123 ++++++---- __tests__/components/LoginModal.test.tsx | 12 +- __tests__/components/Navbar/Navbar.test.tsx | 13 ++ .../Navbar/NavbarMenuItems.test.tsx | 82 ++++++- .../Navbar/UserProfileButton.test.tsx | 43 +--- __tests__/components/ProfileIcon.test.tsx | 8 +- __tests__/components/QRCodeModal.test.tsx | 64 ------ __tests__/components/Toast.test.tsx | 19 +- __tests__/pages/[redirect].test.tsx | 2 +- __tests__/pages/app.test.tsx | 43 ++-- __tests__/pages/dashboard.test.tsx | 12 +- __tests__/utils/formatDate.test.ts | 20 +- __tests__/utils/formatUrl.test.ts | 23 ++ __tests__/utils/validateUrl.test.ts | 25 +-- src/components/App/InputSection.tsx | 50 ----- src/components/App/OutputSection.tsx | 212 ++++++++++++------ src/components/App/ShortenUrlForm.tsx | 101 +++++++++ src/components/Button/index.tsx | 8 +- src/components/CreateNew/index.tsx | 24 -- src/components/Dashboard/NoUrlFound.tsx | 4 +- src/components/Dashboard/UrlListItem.tsx | 137 ++++++----- src/components/Dashboard/dashboard-layout.tsx | 8 +- src/components/Footer/index.tsx | 2 +- src/components/Layout/index.tsx | 22 +- src/components/Loader/index.tsx | 11 +- src/components/LoginModal/index.tsx | 54 +---- src/components/Modal/index.tsx | 50 +++++ src/components/Navbar/DesktopMenu.tsx | 98 ++++++++ src/components/Navbar/MobileMenu.tsx | 89 ++++++++ src/components/Navbar/NavbarMenuItems.tsx | 36 --- src/components/Navbar/UserProfileButton.tsx | 65 +++--- src/components/Navbar/index.tsx | 104 ++++----- src/components/ProfileIcon/ProfileIcon.tsx | 4 +- src/components/QRCodeModal/index.tsx | 84 ------- src/components/Redirect/ErrorPage.tsx | 2 +- src/components/Redirect/LoaderTimer.tsx | 4 +- src/components/Redirect/RedirectFooter.tsx | 2 +- .../ShimmerEffect/DashboardShimmer.tsx | 5 +- .../ShimmerEffect/RedirectShimmer.tsx | 12 +- .../ShimmerEffect/UserLoginShimmer.tsx | 4 +- src/constants/constants.ts | 9 + src/pages/[redirect]/index.tsx | 33 ++- src/pages/_app.tsx | 3 - src/pages/app/index.tsx | 157 ++++++------- src/pages/dashboard/index.tsx | 15 +- src/services/api.tsx | 35 ++- src/styles/global.css | 10 + src/types/button.types.ts | 9 +- src/utils/formatDate.ts | 21 +- src/utils/formatUrl.ts | 6 + src/utils/validateUrl.ts | 24 +- tailwind.config.ts | 9 +- 56 files changed, 1418 insertions(+), 923 deletions(-) delete mode 100644 __tests__/components/App/InputSection.test.tsx create mode 100644 __tests__/components/App/SortenUrlForm.test.tsx delete mode 100644 __tests__/components/CreateNew.test.tsx delete mode 100644 __tests__/components/QRCodeModal.test.tsx create mode 100644 __tests__/utils/formatUrl.test.ts delete mode 100644 src/components/App/InputSection.tsx create mode 100644 src/components/App/ShortenUrlForm.tsx delete mode 100644 src/components/CreateNew/index.tsx create mode 100644 src/components/Modal/index.tsx create mode 100644 src/components/Navbar/DesktopMenu.tsx create mode 100644 src/components/Navbar/MobileMenu.tsx delete mode 100644 src/components/Navbar/NavbarMenuItems.tsx delete mode 100644 src/components/QRCodeModal/index.tsx create mode 100644 src/utils/formatUrl.ts diff --git a/__tests__/components/App/InputSection.test.tsx b/__tests__/components/App/InputSection.test.tsx deleted file mode 100644 index 5c44bec5..00000000 --- a/__tests__/components/App/InputSection.test.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/react'; - -import InputSection from '@/components/App/InputSection'; - -describe('InputSection component', () => { - const testUrl = 'https://stackoverflow.com/questions/26944762/when-to-use-chore-as-type-of-commit-message'; - it('renders InputSection component correctly', () => { - const mockSetUrl = jest.fn(); - const mockHandleUrl = jest.fn(); - render(); - expect(screen.getByPlaceholderText('Enter the URL')).toBeInTheDocument(); - expect(screen.getByText('Shorten')).toBeInTheDocument(); - }); - - it('calls setUrl function on input change', () => { - const mockSetUrl = jest.fn(); - const mockHandleUrl = jest.fn(); - render(); - const inputElement = screen.getByPlaceholderText('Enter the URL'); - fireEvent.change(inputElement, { target: { value: 'https://realdevsquad.com' } }); - expect(mockSetUrl).toHaveBeenCalledWith('https://realdevsquad.com'); - }); - - it('calls handleUrl function on button click', () => { - const mockSetUrl = jest.fn(); - const mockHandleUrl = jest.fn(); - render(); - const generateButton = screen.getByText('Shorten'); - fireEvent.click(generateButton); - expect(mockHandleUrl).toHaveBeenCalled(); - }); -}); diff --git a/__tests__/components/App/OutputSection.test.tsx b/__tests__/components/App/OutputSection.test.tsx index 16167f19..ccc35e1b 100644 --- a/__tests__/components/App/OutputSection.test.tsx +++ b/__tests__/components/App/OutputSection.test.tsx @@ -1,3 +1,5 @@ +import '@testing-library/jest-dom/extend-expect'; + import { fireEvent, render, screen } from '@testing-library/react'; import OutputSection from '@/components/App/OutputSection'; @@ -6,21 +8,65 @@ describe('OutputSection component', () => { const shortUrl = 'https://rds.li/123456'; const originalUrl = 'https://status.realdevsquad.com/task/details/josuets45sds'; - const mockHandleCopyUrl = jest.fn(); const mockHandleCreateNew = jest.fn(); + + beforeAll(() => { + Object.assign(navigator, { + clipboard: { + writeText: jest.fn().mockResolvedValue(() => Promise.resolve()), + }, + }); + }); + + beforeEach(() => { + HTMLCanvasElement.prototype.getContext = jest.fn().mockReturnValue({ + fillRect: jest.fn(), + clearRect: jest.fn(), + getImageData: jest.fn(), + putImageData: jest.fn(), + createImageData: jest.fn(), + setTransform: jest.fn(), + drawImage: jest.fn(), + save: jest.fn(), + fillText: jest.fn(), + restore: jest.fn(), + beginPath: jest.fn(), + moveTo: jest.fn(), + lineTo: jest.fn(), + closePath: jest.fn(), + stroke: jest.fn(), + translate: jest.fn(), + scale: jest.fn(), + rotate: jest.fn(), + arc: jest.fn(), + fill: jest.fn(), + measureText: jest.fn().mockReturnValue({ width: 0 }), + transform: jest.fn(), + rect: jest.fn(), + clip: jest.fn(), + isPointInPath: jest.fn(), + isPointInStroke: jest.fn(), + canvas: document.createElement('canvas'), + }); + + HTMLCanvasElement.prototype.toDataURL = jest.fn().mockReturnValue('data:image/png;base64,mocked'); + + jest.clearAllMocks(); + }); + it('renders OutputSection component correctly', () => { render( ); expect(screen.getByTestId('copy-button')).toBeInTheDocument(); expect(screen.getByTestId('share-button')).toBeInTheDocument(); + expect(screen.getByTestId('output-heading')).toHaveTextContent('Your shortened URL is ready!'); }); it('calls handleCopyUrl function on button click', () => { @@ -29,14 +75,14 @@ describe('OutputSection component', () => { shortUrl={shortUrl} originalUrl={originalUrl} isLoaded={true} - handleCopyUrl={mockHandleCopyUrl} - handleCreateNew={mockHandleCopyUrl} + handleCreateNew={mockHandleCreateNew} /> ); const copyButton = screen.getByTestId('copy-button'); fireEvent.click(copyButton); - expect(mockHandleCopyUrl).toHaveBeenCalled(); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(shortUrl); }); it('opens a new tab when share button is clicked', () => { @@ -45,8 +91,7 @@ describe('OutputSection component', () => { shortUrl={shortUrl} originalUrl={originalUrl} isLoaded={true} - handleCopyUrl={mockHandleCopyUrl} - handleCreateNew={mockHandleCopyUrl} + handleCreateNew={mockHandleCreateNew} /> ); @@ -55,52 +100,98 @@ describe('OutputSection component', () => { expect(shareButton).toHaveAttribute('target', '_blank'); }); - it('renders create new button when window width is less than 768px', () => { + it('renders social media share links', () => { render( ); - Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 500 }); - fireEvent(window, new Event('resize')); - - const createNewButton = screen.getByText('Create New'); - expect(createNewButton).toBeInTheDocument(); + expect(screen.getByTestId('twitter-share')).toBeInTheDocument(); + expect(screen.getByTestId('discord-share')).toBeInTheDocument(); + expect(screen.getByTestId('linkedin-share')).toBeInTheDocument(); + expect(screen.getByTestId('whatsapp-share')).toBeInTheDocument(); }); - it('renders "Create New" button and calls the onClick handler when clicked', () => { + + it('renders shimmer when isLoaded is false', () => { render( + ); + + const shimmer = screen.getByTestId('output-section-shimmer'); + expect(shimmer).toBeInTheDocument(); + }); + + it('updates the button text on download click', async () => { + render( + ); + const downloadButton = screen.getByTestId('download-button'); + fireEvent.click(downloadButton); - const createNewButton = screen.getByText('Create New'); - expect(createNewButton).toBeInTheDocument(); - fireEvent.click(createNewButton); - expect(mockHandleCreateNew).toHaveBeenCalled(); + const updatedText = await screen.findByText('Downloaded'); + expect(updatedText).toBeInTheDocument(); }); - it('renders shimmer when isLoaded is false', () => { + it('triggers the download process correctly', () => { render( + ); + + const downloadButton = screen.getByTestId('download-button'); + fireEvent.click(downloadButton); + expect(HTMLCanvasElement.prototype.toDataURL).toHaveBeenCalled(); + }); + + it('does nothing when canvas is not found during download', () => { + document.getElementById = jest.fn().mockReturnValue(null); + + render( + ); - const shimmer = screen.getByTestId('output-section-shimmer'); - expect(shimmer).toBeInTheDocument(); + const downloadButton = screen.getByTestId('download-button'); + fireEvent.click(downloadButton); + + expect(HTMLCanvasElement.prototype.toDataURL).not.toHaveBeenCalled(); + }); + + it('does nothing when shortUrl is empty during copy', () => { + render( + + ); + + const copyButton = screen.getByTestId('copy-button'); + fireEvent.click(copyButton); + + expect(navigator.clipboard.writeText).not.toHaveBeenCalled(); }); }); diff --git a/__tests__/components/App/SortenUrlForm.test.tsx b/__tests__/components/App/SortenUrlForm.test.tsx new file mode 100644 index 00000000..05f83e96 --- /dev/null +++ b/__tests__/components/App/SortenUrlForm.test.tsx @@ -0,0 +1,134 @@ +import { fireEvent, render, screen } from '@testing-library/react'; + +import ShortenUrlForm, { HomeText } from '@/components/App/ShortenUrlForm'; + +describe('UrlForm component', () => { + const testUrl = 'https://stackoverflow.com/questions/26944762/when-to-use-chore-as-type-of-commit-message'; + + it('renders UrlForm component correctly', () => { + const mockSetUrl = jest.fn(); + const mockOnSubmit = jest.fn(); + const mockClearError = jest.fn(); + render( + + ); + expect(screen.getByPlaceholderText('Enter the URL')).toBeInTheDocument(); + expect(screen.getByTestId('shorten-button')).toBeInTheDocument(); + }); + + it('calls setUrl function on input change and clearError', () => { + const mockSetUrl = jest.fn(); + const mockOnSubmit = jest.fn(); + const mockClearError = jest.fn(); + render( + + ); + const inputElement = screen.getByPlaceholderText('Enter the URL'); + fireEvent.change(inputElement, { target: { value: 'https://realdevsquad.com' } }); + expect(mockSetUrl).toHaveBeenCalledWith('https://realdevsquad.com'); + expect(mockClearError).toHaveBeenCalled(); + }); + + it('calls onSubmit function on button click', () => { + const mockSetUrl = jest.fn(); + const mockOnSubmit = jest.fn(); + const mockClearError = jest.fn(); + render( + + ); + const generateButton = screen.getByTestId('shorten-button'); + fireEvent.click(generateButton); + expect(mockOnSubmit).toHaveBeenCalledWith(testUrl); + }); + + it('does not call onSubmit function if URL is empty', () => { + const mockSetUrl = jest.fn(); + const mockOnSubmit = jest.fn(); + const mockClearError = jest.fn(); + + render( + + ); + + const generateButton = screen.getByTestId('shorten-button'); + fireEvent.click(generateButton); + + expect(mockOnSubmit).not.toHaveBeenCalled(); + }); + + it('renders error message if error prop is passed', () => { + const errorMessage = 'Enter a valid URL'; + render( + + ); + const errorElement = screen.getByText(errorMessage); + expect(errorElement).toBeInTheDocument(); + expect(errorElement).toHaveTextContent(errorMessage); + }); + + it('renders the main heading correctly', () => { + render(); + const mainHeading = screen.getByText('Shorten Your URL'); + expect(mainHeading).toBeInTheDocument(); + expect(mainHeading).toHaveClass( + 'text-3xl md:text-6xl xl:text-7xl sm:text-5xl text-center text-white font-semibold pb-2 lg:pb-4' + ); + }); + + it('renders the subheading correctly', () => { + render(); + const subHeading = screen.getByText('Perfect Links Every Time'); + expect(subHeading).toBeInTheDocument(); + expect(subHeading).toHaveClass( + 'text-2xl sm:text-3xl md:text-4xl xl:text-5xl text-center text-white font-semibold' + ); + }); + + it('renders the paragraph text correctly', () => { + render(); + const paragraph = screen.getByText(/Ready to shorten your URL\? Enter your/i); + expect(paragraph).toBeInTheDocument(); + expect(paragraph).toHaveClass('xl:text-xl text-base text-white mt-4 text-center'); + }); + + it('renders the paragraph text with a line break for small screens', () => { + render(); + const paragraph = screen.getByText(/Ready to shorten your URL\? Enter your/i); + expect(paragraph.innerHTML).toContain('
'); + }); +}); diff --git a/__tests__/components/CreateNew.test.tsx b/__tests__/components/CreateNew.test.tsx deleted file mode 100644 index 3b0e793d..00000000 --- a/__tests__/components/CreateNew.test.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { render } from '@testing-library/react'; -import router from 'next/router'; -import React from 'react'; - -import CreateNew from '@/components/CreateNew'; - -jest.mock('next/router', () => ({ - useRouter() { - return { - pathname: '/', - }; - }, -})); - -describe('CreateNew', () => { - it('should not show this link on the home page', () => { - router.pathname = '/'; - const { queryByTestId } = render(); - const link = queryByTestId('create-new-link'); - expect(link).not.toBeInTheDocument(); - }); -}); diff --git a/__tests__/components/Dashhboard/UrlListItem.test.tsx b/__tests__/components/Dashhboard/UrlListItem.test.tsx index ff8c3ce9..626e86dd 100644 --- a/__tests__/components/Dashhboard/UrlListItem.test.tsx +++ b/__tests__/components/Dashhboard/UrlListItem.test.tsx @@ -1,98 +1,91 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from 'react-query'; -import UrlListItem from '@/components/Dashboard/UrlListItem'; +import UrlListItem, { CopyButton, DeleteButton } from '@/components/Dashboard/UrlListItem'; import useAuthenticated from '@/hooks/useAuthenticated'; import { deleteUrlApi } from '@/services/api'; import { urls } from '../../../__mocks__/db/urls'; -jest.mock('@/hooks/useAuthenticated'); -jest.mock('@/services/api'); +jest.mock('@/hooks/useAuthenticated', () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mock('@/services/api', () => ({ + deleteUrlApi: jest.fn(), +})); describe('UrlListItem', () => { const queryClient = new QueryClient(); const url = urls.urls[0]; - const copyButtonHandler = (url) => { + const copyButtonHandler = (url: string) => { navigator.clipboard.writeText(url); }; + const mockWriteText = jest.fn(); - global.navigator.clipboard = { writeText: mockWriteText }; + beforeAll(() => { + Object.defineProperty(navigator, 'clipboard', { + value: { + writeText: mockWriteText, + }, + }); + }); beforeEach(() => { - useAuthenticated.mockReturnValue({ - userData: { data: { id: 'user123' } }, + (useAuthenticated as jest.Mock).mockReturnValue({ + userData: { data: { id: 123 } }, }); }); test('renders UrlListItem component', () => { render( - { - copyButtonHandler(url.originalUrl); - }} - /> + ); - const linkElement = screen.getByText(`${url.originalUrl}`); - expect(linkElement).toBeInTheDocument(); + const originalUrlElement = screen.getByText(url.originalUrl); + expect(originalUrlElement).toBeInTheDocument(); }); test('copy button works', () => { render( - { - copyButtonHandler(url.originalUrl); - }} - /> + ); - const copyButton = screen.getByTestId('copy-button'); + const copyButton = screen.getAllByTestId('copy-button')[0]; fireEvent.click(copyButton); - expect(mockWriteText).toHaveBeenCalledWith(url.originalUrl); + expect(mockWriteText).toHaveBeenCalledWith(`https://staging-tinysite.realdevsquad.com/${url.shortUrl}`); }); test('delete button works', async () => { - deleteUrlApi.mockResolvedValueOnce({}); + (deleteUrlApi as jest.Mock).mockResolvedValueOnce({}); render( - { - copyButtonHandler(url.originalUrl); - }} - /> + ); - const deleteButton = screen.getByText('Delete'); - fireEvent.click(deleteButton); + const deleteButtons = screen.getAllByTestId('delete-button'); + fireEvent.click(deleteButtons[0]); await waitFor(() => { - expect(deleteUrlApi).toHaveBeenCalledWith({ id: url.id, userId: 'user123' }); + expect(deleteUrlApi).toHaveBeenCalledWith({ id: url.id, userId: 123 }); }); }); test('shows error on delete failure', async () => { const alertMock = jest.spyOn(window, 'alert').mockImplementation(() => 'Alert called'); - deleteUrlApi.mockRejectedValueOnce(new Error('Error deleting URL')); + (deleteUrlApi as jest.Mock).mockRejectedValueOnce(new Error('Error deleting URL')); render( - { - copyButtonHandler(url.originalUrl); - }} - /> + ); - const deleteButton = screen.getByText('Delete'); - fireEvent.click(deleteButton); + const deleteButtons = screen.getAllByTestId('delete-button'); + fireEvent.click(deleteButtons[0]); await waitFor(() => { expect(alertMock).toHaveBeenCalledWith('Error deleting URL'); @@ -101,3 +94,49 @@ describe('UrlListItem', () => { alertMock.mockRestore(); }); }); + +describe('DeleteButton', () => { + const onDelete = jest.fn(); + + it('renders correctly', () => { + render(); + const deleteButton = screen.getByTestId('delete-button'); + expect(deleteButton).toBeInTheDocument(); + }); + + it('triggers onDelete when clicked', () => { + render(); + const deleteButton = screen.getByTestId('delete-button'); + fireEvent.click(deleteButton); + expect(onDelete).toHaveBeenCalledTimes(1); + }); + + it('displays loader when isLoading is true', () => { + render(); + const loader = screen.getByTestId('loader'); + expect(loader).toBeInTheDocument(); + }); + + it('disables button when isLoading is true', () => { + render(); + const deleteButton = screen.getByTestId('delete-button'); + expect(deleteButton).toBeDisabled(); + }); +}); + +describe('CopyButton', () => { + const onCopy = jest.fn(); + + it('renders correctly', () => { + render(); + const copyButton = screen.getByTestId('copy-button'); + expect(copyButton).toBeInTheDocument(); + }); + + it('triggers onCopy when clicked', () => { + render(); + const copyButton = screen.getByTestId('copy-button'); + fireEvent.click(copyButton); + expect(onCopy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/__tests__/components/LoginModal.test.tsx b/__tests__/components/LoginModal.test.tsx index e9992790..f6f06d94 100644 --- a/__tests__/components/LoginModal.test.tsx +++ b/__tests__/components/LoginModal.test.tsx @@ -13,18 +13,12 @@ describe('LoginModal Component', () => { }} /> ); - const closeButton = screen.getByTestId('close-login-modal'); + const closeButton = screen.getByTestId('close-modal'); expect(closeButton).toBeInTheDocument(); }); test('renders the LoginModal component with title and sign in with google button', () => { - render( - { - onClose(); - }} - /> - ); + render(); const title = screen.getByText('Please log in'); expect(title).toBeInTheDocument(); const signInWithGoogleButton = screen.getByTestId('sign-in-with-google-button'); @@ -39,7 +33,7 @@ describe('LoginModal Component', () => { }} /> ); - const modal = screen.getByTestId('login-modal'); + const modal = screen.getByTestId('modal'); expect(modal).toBeInTheDocument(); const body = document.querySelector('body'); fireEvent.mouseDown(body as HTMLElement); diff --git a/__tests__/components/Navbar/Navbar.test.tsx b/__tests__/components/Navbar/Navbar.test.tsx index 64e418d2..82515584 100644 --- a/__tests__/components/Navbar/Navbar.test.tsx +++ b/__tests__/components/Navbar/Navbar.test.tsx @@ -1,6 +1,7 @@ import '@testing-library/jest-dom'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { useRouter } from 'next/router'; import React from 'react'; import { QueryClient, QueryClientProvider } from 'react-query'; @@ -8,7 +9,19 @@ import Navbar from '@/components/Navbar/'; import user from '../../../__mocks__/db/user'; +jest.mock('next/router', () => ({ + useRouter: jest.fn(), +})); + describe('Navbar', () => { + const mockUseRouter = useRouter as jest.MockedFunction; + + beforeEach(() => { + mockUseRouter.mockReturnValue({ + pathname: '/', + push: jest.fn(), + } as any); + }); const queryClient = new QueryClient(); it('should render shimmer when loading', () => { diff --git a/__tests__/components/Navbar/NavbarMenuItems.test.tsx b/__tests__/components/Navbar/NavbarMenuItems.test.tsx index afb85b75..912f0e67 100644 --- a/__tests__/components/Navbar/NavbarMenuItems.test.tsx +++ b/__tests__/components/Navbar/NavbarMenuItems.test.tsx @@ -1,13 +1,79 @@ -import { render } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { useRouter } from 'next/router'; import React from 'react'; -import NavbarMenuItems from '@/components/Navbar/NavbarMenuItems'; +import DesktopMenu from '@/components/Navbar/DesktopMenu'; -describe('NavbarMenuItems', () => { - it('should render', () => { - const { container } = render(); - expect(container).toContainHTML('Create New'); - expect(container).toContainHTML('Dashboard'); - expect(container).toContainHTML('Sign Out'); +jest.mock('next/router', () => ({ + useRouter: jest.fn(), +})); + +describe('DesktopMenu', () => { + const defaultProps = { + isLoading: false, + isLoggedIn: false, + firstName: 'John', + lastName: 'Doe', + handleProfileClick: jest.fn(), + setShowLoginModal: jest.fn(), + }; + + beforeEach(() => { + (useRouter as jest.Mock).mockReturnValue({ + pathname: '/', + }); + }); + + it('should render the menu items', () => { + render(); + expect(screen.getByText('Home')).toBeInTheDocument(); + expect(screen.getByText('Dashboard')).toBeInTheDocument(); + }); + + it('should highlight the Home link when on the Home page', () => { + render(); + const homeLink = screen.getByText('Home').closest('li'); + expect(homeLink).toHaveClass('border-b-2 border-white'); + }); + + it('should highlight the Dashboard link when on the Dashboard page', () => { + (useRouter as jest.Mock).mockReturnValue({ + pathname: '/dashboard', + }); + + render(); + const dashboardLink = screen.getByText('Dashboard').closest('li'); + expect(dashboardLink).toHaveClass('border-b-2 border-white'); + }); + + it('should not highlight the Home link when on the Dashboard page', () => { + (useRouter as jest.Mock).mockReturnValue({ + pathname: '/dashboard', + }); + + render(); + const homeLink = screen.getByText('Home').closest('li'); + expect(homeLink).not.toHaveClass('border-b-2 border-white'); + }); + + it('should render the user profile button when not loading and logged in', () => { + render(); + const profileIcon = screen.getByTestId('profile-icon'); + expect(profileIcon).toBeInTheDocument(); + expect(profileIcon).toHaveTextContent('JD'); + }); + + it('should display shimmer effect when loading', () => { + render(); + expect(screen.getByTestId('user-login-shimmer')).toBeInTheDocument(); + }); + + it('should show sign out button on profile click', () => { + render(); + + const profileButton = screen.getByTestId('profile-icon'); + fireEvent.click(profileButton); + + expect(screen.getByText('Sign Out')).toBeInTheDocument(); }); }); diff --git a/__tests__/components/Navbar/UserProfileButton.test.tsx b/__tests__/components/Navbar/UserProfileButton.test.tsx index 5a611fdb..8964b580 100644 --- a/__tests__/components/Navbar/UserProfileButton.test.tsx +++ b/__tests__/components/Navbar/UserProfileButton.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import React from 'react'; import UserProfileButton from '@/components/Navbar/UserProfileButton'; @@ -10,15 +10,12 @@ describe('UserProfileButton', () => { isLoggedIn={false} firstName="User" lastName="" - handleMenuClick={() => jest.fn()} + handleProfileClick={() => jest.fn()} setShowLoginModal={() => jest.fn()} - isMenuOpen={false} /> ); - await waitFor(() => { - const loginButton = screen.getByText(/sign in/i); - expect(loginButton).toBeInTheDocument(); - }); + const loginButton = screen.getByText(/sign in/i); + expect(loginButton).toBeInTheDocument(); }); it('should render user profile button', () => { @@ -27,13 +24,13 @@ describe('UserProfileButton', () => { isLoggedIn={true} firstName="User" lastName="" - handleMenuClick={() => jest.fn()} + handleProfileClick={() => jest.fn()} setShowLoginModal={() => jest.fn()} - isMenuOpen={false} /> ); - expect(screen.getByText('User')).toBeInTheDocument(); + expect(screen.getByText('U')).toBeInTheDocument(); }); + it('should show login modal when sign in button is clicked', async () => { const setShowLoginModal = jest.fn(); render( @@ -41,30 +38,12 @@ describe('UserProfileButton', () => { isLoggedIn={false} firstName="User" lastName="" - handleMenuClick={() => jest.fn()} + handleProfileClick={() => jest.fn()} setShowLoginModal={setShowLoginModal} - isMenuOpen={false} - /> - ); - await waitFor(() => { - const loginButton = screen.getByText(/sign in/i); - loginButton.click(); - expect(setShowLoginModal).toHaveBeenCalledTimes(1); - }); - }); - - it('should rotate arrow when menu is open', () => { - render( - jest.fn()} - setShowLoginModal={() => jest.fn()} - isMenuOpen={true} /> ); - const arrow = screen.getByTestId('user-profile-button-arrow'); - expect(arrow).toHaveClass('rotate-180'); + const loginButton = screen.getByText(/sign in/i); + fireEvent.click(loginButton); + expect(setShowLoginModal).toHaveBeenCalledTimes(1); }); }); diff --git a/__tests__/components/ProfileIcon.test.tsx b/__tests__/components/ProfileIcon.test.tsx index aac3a704..b6f14800 100644 --- a/__tests__/components/ProfileIcon.test.tsx +++ b/__tests__/components/ProfileIcon.test.tsx @@ -5,12 +5,12 @@ import ProfileIcon from '@/components/ProfileIcon/ProfileIcon'; describe('ProfileIcon', () => { it('should have the correct initials', () => { - const { container } = render(); - expect(container).toHaveTextContent('SS'); + const { container } = render(); + expect(container).toHaveTextContent('AD'); }); it('should have the correct initials if no last name is provided', () => { - const { container } = render(); - expect(container).toHaveTextContent('S'); + const { container } = render(); + expect(container).toHaveTextContent('A'); }); }); diff --git a/__tests__/components/QRCodeModal.test.tsx b/__tests__/components/QRCodeModal.test.tsx deleted file mode 100644 index 9ab718e7..00000000 --- a/__tests__/components/QRCodeModal.test.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/react'; - -import QRCodeModal from '@/components/QRCodeModal'; - -describe('QRCodeModal Component', () => { - const onClose = jest.fn(); - - test('renders the QRCodeModal component', () => { - render( - { - onClose(); - }} - shortUrl="https://www.rds.com" - /> - ); - const closeButton = screen.getByTestId('close-qrcode-modal'); - expect(closeButton).toBeInTheDocument(); - const downloadButton = screen.getByTestId('download-qr-code'); - expect(downloadButton).toBeInTheDocument(); - const qrcodeCanvas = screen.getByTestId('qrcode'); - expect(qrcodeCanvas).toBeInTheDocument(); - }); - - test('closes the modal when clicking outside the modal', () => { - render( - { - onClose(); - }} - shortUrl="https://www.rds.com" - /> - ); - const modal = screen.getByTestId('qrcode-modal'); - expect(modal).toBeInTheDocument(); - const body = document.querySelector('body'); - fireEvent.mouseDown(body as HTMLElement); - expect(onClose).toHaveBeenCalledTimes(1); - }); - - test('download image by clicking download button', () => { - render( - { - onClose(); - }} - shortUrl="https://www.rds.com" - /> - ); - - const downloadButton = screen.getByTestId('download-qr-code'); - expect(downloadButton).toBeInTheDocument(); - - HTMLCanvasElement.prototype.toDataURL = jest.fn(() => 'data:image/png;base64,abcd1234'); - document.body.appendChild = jest.fn(); - document.body.removeChild = jest.fn(); - - fireEvent.click(downloadButton); - - expect(HTMLCanvasElement.prototype.toDataURL).toHaveBeenCalledWith('image/png'); - expect(document.body.appendChild).toHaveBeenCalled(); - expect(document.body.removeChild).toHaveBeenCalled(); - }); -}); diff --git a/__tests__/components/Toast.test.tsx b/__tests__/components/Toast.test.tsx index c5c31971..dc6dc859 100644 --- a/__tests__/components/Toast.test.tsx +++ b/__tests__/components/Toast.test.tsx @@ -9,6 +9,7 @@ describe('Toast', () => { test('should render the toast component with the message', () => { render( { test('should not call onDismiss function when the timeToShow is not completed', () => { render( { test('should call onDismiss function when the timeToShow is completed', async () => { render( { }); test('should render the error toast component with the message', () => { - render(); + render( + + ); const toastDiv = screen.getByTestId('toast-div'); @@ -60,7 +72,9 @@ describe('Toast', () => { }); test('should render the info toast component with the message', () => { - render(); + render( + + ); const toastDiv = screen.getByTestId('toast-div'); expect(screen.getByTestId('toast')).toBeInTheDocument(); @@ -71,6 +85,7 @@ describe('Toast', () => { test('should become invisible when isVisible is false', async () => { render( { ); - expect(screen.getByTestId('loader')).toHaveTextContent('3'); + expect(screen.getByTestId('loader')).toHaveTextContent('5'); jest.advanceTimersByTime(5000); await waitFor(() => { const redirectUrl = screen.getByText(urlDetails.url.originalUrl); diff --git a/__tests__/pages/app.test.tsx b/__tests__/pages/app.test.tsx index 4b62e5d4..0d89154a 100644 --- a/__tests__/pages/app.test.tsx +++ b/__tests__/pages/app.test.tsx @@ -1,4 +1,5 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { useRouter } from 'next/router'; import { QueryClient, QueryClientProvider } from 'react-query'; import useAuthenticated from '@/hooks/useAuthenticated'; @@ -6,6 +7,10 @@ import App from '@/pages/app'; import { userData } from '../../fixtures/users'; +jest.mock('next/router', () => ({ + useRouter: jest.fn(), +})); + jest.mock('../../src/hooks/useAuthenticated', () => ({ __esModule: true, default: jest.fn(), @@ -14,15 +19,21 @@ jest.mock('../../src/hooks/useAuthenticated', () => ({ describe('App Component', () => { const mockWriteText = jest.fn(); const mockUseAuthenticated = useAuthenticated as jest.Mock; + const mockUseRouter = useRouter as jest.Mock; global.navigator.clipboard = { writeText: mockWriteText }; const queryClient = new QueryClient(); + beforeEach(() => { + jest.clearAllMocks(); + }); + test('renders the App component with input box and button', () => { mockUseAuthenticated.mockReturnValue({ isLoggedIn: true, userData: userData.data, }); + mockUseRouter.mockReturnValue({}); render( @@ -36,6 +47,8 @@ describe('App Component', () => { }); test('updates input box value when text is entered', () => { + mockUseRouter.mockReturnValue({}); + render( @@ -47,6 +60,7 @@ describe('App Component', () => { }); test('shows error toast when invalid URL is entered', async () => { + mockUseRouter.mockReturnValue({}); render( @@ -65,6 +79,7 @@ describe('App Component', () => { isLoggedIn: false, userData: undefined, }); + mockUseRouter.mockReturnValue({}); render( @@ -83,6 +98,8 @@ describe('App Component', () => { isLoggedIn: true, userData: userData.data, }); + mockUseRouter.mockReturnValue({}); + render( @@ -101,6 +118,8 @@ describe('App Component', () => { isLoggedIn: false, userData: undefined, }); + mockUseRouter.mockReturnValue({}); + render( @@ -110,7 +129,7 @@ describe('App Component', () => { const generateButton = screen.getByText('Shorten'); fireEvent.change(urlInput, { target: { value: 'https://www.longurl.com' } }); fireEvent.click(generateButton); - const closeButton = await screen.findByTestId('close-login-modal'); + const closeButton = await screen.findByTestId('close-modal'); fireEvent.click(closeButton); const loginModal = screen.queryByText('Log in to generate short links'); expect(loginModal).not.toBeInTheDocument(); @@ -121,6 +140,8 @@ describe('App Component', () => { isLoggedIn: true, userData: userData.data, }); + mockUseRouter.mockReturnValue({}); + render( @@ -134,24 +155,4 @@ describe('App Component', () => { fireEvent.click(copyButton); await waitFor(() => expect(mockWriteText).toBeTruthy()); }); - - test('shows input section when user clicks on create new button', async () => { - mockUseAuthenticated.mockReturnValue({ - isLoggedIn: true, - userData: userData.data, - }); - render( - - - - ); - const urlInput = screen.getByPlaceholderText('Enter the URL'); - const generateButton = screen.getByText('Shorten'); - fireEvent.change(urlInput, { target: { value: 'https://www.longurl.com' } }); - fireEvent.click(generateButton); - const createNewButton = await screen.findByTestId('create-new-button'); - fireEvent.click(createNewButton); - const inputSection = screen.queryByTestId('input-section'); - expect(inputSection).toBeInTheDocument(); - }); }); diff --git a/__tests__/pages/dashboard.test.tsx b/__tests__/pages/dashboard.test.tsx index fe11aa0d..8c1b9e21 100644 --- a/__tests__/pages/dashboard.test.tsx +++ b/__tests__/pages/dashboard.test.tsx @@ -1,4 +1,5 @@ import { render, screen } from '@testing-library/react'; +import { useRouter } from 'next/router'; import React from 'react'; import { QueryClient, QueryClientProvider } from 'react-query'; @@ -13,6 +14,10 @@ jest.mock('../../src/services/api', () => ({ useGetUrlsQuery: jest.fn(), })); +jest.mock('next/router', () => ({ + useRouter: jest.fn(), +})); + jest.mock('../../src/hooks/useAuthenticated', () => ({ __esModule: true, default: jest.fn(), @@ -23,6 +28,7 @@ describe('Dashboard', () => { const mockUseAuthenticated = useAuthenticated as jest.Mock; const mockUseGetUrlsQuery = useGetUrlsQuery as jest.Mock; const mockCopyToClipboard = jest.fn(); + const mockUseRouter = useRouter as jest.Mock; beforeAll(() => { Object.assign(navigator, { @@ -37,6 +43,7 @@ describe('Dashboard', () => { isLoggedIn: true, userData: userData.data, }); + mockUseRouter.mockReturnValue({}); mockUseGetUrlsQuery.mockReturnValue({ data: undefined, isLoading: true, @@ -71,6 +78,7 @@ describe('Dashboard', () => { isLoggedIn: false, userData: undefined, }); + mockUseRouter.mockReturnValue({}); mockUseGetUrlsQuery.mockReturnValue({ data: undefined, isLoading: false, @@ -80,9 +88,9 @@ describe('Dashboard', () => { ); - expect(screen.getByTestId('login-modal')).toBeInTheDocument(); + expect(screen.getByTestId('modal')).toBeInTheDocument(); expect(screen.getByText('Login to view your URLs and create new ones')).toBeInTheDocument(); - const closeButton = screen.getByTestId('close-login-modal'); + const closeButton = screen.getByTestId('close-modal'); closeButton.click(); }); diff --git a/__tests__/utils/formatDate.test.ts b/__tests__/utils/formatDate.test.ts index adc2d6a7..4bb75675 100644 --- a/__tests__/utils/formatDate.test.ts +++ b/__tests__/utils/formatDate.test.ts @@ -47,13 +47,13 @@ describe('formatDate', () => { expect(result).toEqual('1d ago'); }); - it('should return Aug 5, 2021', () => { + it('should return 5 August 2021', () => { const result = formatDate({ inputDate: '2021-08-05T00:00:00.000Z', relativeDuration: false, fullDate: false, }); - expect(result).toEqual('August 5, 2021'); + expect(result).toEqual('05 August 2021'); }); it('should return full date and time', () => { @@ -64,15 +64,15 @@ describe('formatDate', () => { }); const expectedDate = new Date('2021-08-09T17:59:59.000Z'); - const expectedFormattedDate = expectedDate.toLocaleString('en-US', { - year: 'numeric', + const expectedFormattedDate = `${expectedDate.toLocaleDateString('en-GB', { + day: '2-digit', month: 'long', - day: 'numeric', - hour: 'numeric', - minute: 'numeric', - second: 'numeric', - hour12: true, - }); + year: 'numeric', + })} ${expectedDate.toLocaleTimeString('en-GB', { + hour: '2-digit', + minute: '2-digit', + hour12: false, + })}`; expect(result).toEqual(expectedFormattedDate); }); diff --git a/__tests__/utils/formatUrl.test.ts b/__tests__/utils/formatUrl.test.ts new file mode 100644 index 00000000..ccf505d0 --- /dev/null +++ b/__tests__/utils/formatUrl.test.ts @@ -0,0 +1,23 @@ +import { formatUrl } from '@/utils/formatUrl'; + +describe('formatUrl', () => { + it('should return the URL as is if it starts with http://', () => { + const url = 'http://example.com'; + expect(formatUrl(url)).toBe(url); + }); + + it('should return the URL as is if it starts with https://', () => { + const url = 'https://example.com'; + expect(formatUrl(url)).toBe(url); + }); + + it('should prepend https:// if the URL does not start with http:// or https://', () => { + const url = 'example.com'; + expect(formatUrl(url)).toBe(`https://${url}`); + }); + + it('should handle URLs with no scheme correctly', () => { + const url = 'example'; + expect(formatUrl(url)).toBe(`https://${url}`); + }); +}); diff --git a/__tests__/utils/validateUrl.test.ts b/__tests__/utils/validateUrl.test.ts index 104bf1a3..99ddb3dc 100644 --- a/__tests__/utils/validateUrl.test.ts +++ b/__tests__/utils/validateUrl.test.ts @@ -1,24 +1,15 @@ import validateUrl from '@/utils/validateUrl'; describe('validateUrl', () => { - const showToast = jest.fn(); - - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('should return false if url is empty', () => { - expect(validateUrl('', showToast)).toBe(false); - expect(showToast).toHaveBeenCalledWith('Enter the URL', 3000, 'error'); - }); - - it('should return false if url is not valid', () => { - expect(validateUrl('https://rds', showToast)).toBe(false); - expect(showToast).toHaveBeenCalledWith('Enter a valid URL', 3000, 'info'); + it('should return an error message if URL is empty', () => { + const result = validateUrl(''); + expect(result.isValid).toBe(false); + expect(result.errorMessage).toBe('Enter a valid URL'); }); - it('should return true if url is valid', () => { - expect(validateUrl('https://www.rds.li', showToast)).toBe(true); - expect(showToast).not.toHaveBeenCalled(); + it('should return null if URL is valid', () => { + const result = validateUrl('https://www.rds.li'); + expect(result.isValid).toBe(true); + expect(result.errorMessage).toBeNull(); }); }); diff --git a/src/components/App/InputSection.tsx b/src/components/App/InputSection.tsx deleted file mode 100644 index dedb2af2..00000000 --- a/src/components/App/InputSection.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import React, { ChangeEvent, FormEvent } from 'react'; - -import Button from '@/components/Button'; - -interface InputSectionProps { - url: string; - setUrl: (url: string) => void; - handleUrl: () => void; -} - -const InputSection: React.FC = ({ url, setUrl, handleUrl }) => ( -
{ - e.preventDefault(); - handleUrl(); - }} - data-testid="input-section" - > -

- Enter a URL to shorten -

- -
-
- - ) => setUrl(e.target.value)} - value={url} - placeholder="Enter the URL" - name="URL" - /> -
- -
-
-); - -export default InputSection; diff --git a/src/components/App/OutputSection.tsx b/src/components/App/OutputSection.tsx index a054b217..527a8c15 100644 --- a/src/components/App/OutputSection.tsx +++ b/src/components/App/OutputSection.tsx @@ -1,12 +1,20 @@ import Link from 'next/link'; -import React from 'react'; -import { AiOutlineArrowDown } from 'react-icons/ai'; -import { IoIosCopy, IoIosShareAlt } from 'react-icons/io'; -import { LuQrCode } from 'react-icons/lu'; +import QRCode from 'qrcode.react'; +import React, { useState } from 'react'; +import { FaCheck, FaRegCopy } from 'react-icons/fa'; +import { FaWhatsapp } from 'react-icons/fa6'; +import { FaDiscord, FaLinkedin, FaXTwitter } from 'react-icons/fa6'; +import { HiOutlineDownload } from 'react-icons/hi'; +import { PiShareFatBold } from 'react-icons/pi'; import Button from '@/components/Button'; -import QRCodeModal from '@/components/QRCodeModal'; -import { removeProtocol } from '@/constants/constants'; +import { + discordShareUrl, + linkedinShareUrl, + removeProtocol, + twitterShareUrl, + whatsappShareUrl, +} from '@/constants/constants'; import OutputSectionShimmer from '../ShimmerEffect/OutputSectionShimmer'; @@ -15,83 +23,145 @@ interface OutputSectionProps { shortUrl: string; isLoaded: boolean; handleCreateNew: () => void; - handleCopyUrl: () => void; } -const OutputSection: React.FC = ({ - shortUrl, - originalUrl, - isLoaded, - handleCopyUrl, - handleCreateNew, -}) => { - const [showQRCodeModal, setShowQRCodeModal] = React.useState(false); +const OutputSection: React.FC = ({ shortUrl, isLoaded }) => { + const [downloaded, setDownloaded] = useState(false); + const [copied, setCopied] = useState(false); + + const CopyActionIcon = copied ? FaCheck : FaRegCopy; + const DownloadActionIcon = downloaded ? FaCheck : HiOutlineDownload; + const DownloadButtonText = downloaded ? 'Downloaded' : 'Download'; + if (!isLoaded) { - return ; + return ; } + const handleDownload = () => { + const canvas = document.getElementById('qr-code') as HTMLCanvasElement; + if (!canvas) return; + + const pngUrl = canvas.toDataURL('image/png').replace('image/png', 'image/octet-stream'); + const downloadLink = document.createElement('a'); + downloadLink.href = pngUrl; + downloadLink.download = `${shortUrl.split('/').pop()}.png`; + document.body.appendChild(downloadLink); + downloadLink.click(); + document.body.removeChild(downloadLink); + setDownloaded(true); + }; + + const handleCopyUrl = () => { + if (!shortUrl) return; + + navigator.clipboard.writeText(shortUrl); + setCopied(true); + }; + return ( - <> -
+

+ Your shortened URL is ready! +

+ + +
+ + {shortUrl.replace(removeProtocol, '')} - - -
- - {shortUrl.replace(removeProtocol, '')} - -
- - -   - - +
+ + + - -
+
- -
- {showQRCodeModal && setShowQRCodeModal(false)} />} - + + + + ); }; diff --git a/src/components/App/ShortenUrlForm.tsx b/src/components/App/ShortenUrlForm.tsx new file mode 100644 index 00000000..5876ba4b --- /dev/null +++ b/src/components/App/ShortenUrlForm.tsx @@ -0,0 +1,101 @@ +import React, { ChangeEvent, FormEvent } from 'react'; +import { FaLink } from 'react-icons/fa6'; + +import Button from '@/components/Button'; + +interface ErrorMessageProps { + message: string; +} + +interface ShortenUrlFormProps { + url: string; + error: string | null; + clearError: () => void; + setUrl: (url: string) => void; + onSubmit: (url: string) => void; + loading: boolean; +} + +export const HomeText: React.FC = () => { + return ( +
+

+ Shorten Your URL +

+ +

+ Perfect Links Every Time +

+ +

+ Ready to shorten your URL? Enter your +
URL below +

+
+ ); +}; + +const ErrorMessage: React.FC = ({ message }) => { + return ( +

+ {message} +

+ ); +}; + +const ShortenUrlForm: React.FC = ({ url, setUrl, onSubmit, error, clearError, loading }) => { + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + const formData = new FormData(e.target as HTMLFormElement); + const url = formData.get('URL') as string | null; + + if (!url) return; + + onSubmit(url); + }; + + const handleUrlChange = (e: ChangeEvent) => { + setUrl(e.target.value); + clearError(); + }; + + const inputBorderClass = error ? 'border-2 border-red-500' : ''; + + return ( + <> + +
+
+
+ + {error && } +
+ + +
+
+ + ); +}; + +export default ShortenUrlForm; diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx index cdedaa7c..bf5b14ce 100644 --- a/src/components/Button/index.tsx +++ b/src/components/Button/index.tsx @@ -2,9 +2,12 @@ import React from 'react'; import { ButtonProps } from '@/types/button.types'; -const Button: React.FC = ({ type, className, onClick, children, disabled, testId }) => { +import { Loader } from '../Loader'; + +const Button: React.FC = ({ type, className, onClick, children, disabled, testId, loading }) => { return ( - ); @@ -13,6 +16,7 @@ const Button: React.FC = ({ type, className, onClick, children, dis Button.defaultProps = { type: 'button', className: 'w-full bg-gray-200 hover:bg-gray-300 ', + loading: false, }; export default Button; diff --git a/src/components/CreateNew/index.tsx b/src/components/CreateNew/index.tsx deleted file mode 100644 index 0d21b681..00000000 --- a/src/components/CreateNew/index.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import Link from 'next/link'; -import React, { useRouter } from 'next/router'; -import { FaPlus } from 'react-icons/fa'; - -function CreateNew() { - const router = useRouter(); - const { pathname } = router; - - if (pathname === '/') { - return null; - } - return ( - - - Create New - - ); -} - -export default CreateNew; diff --git a/src/components/Dashboard/NoUrlFound.tsx b/src/components/Dashboard/NoUrlFound.tsx index b67bb077..b50be930 100644 --- a/src/components/Dashboard/NoUrlFound.tsx +++ b/src/components/Dashboard/NoUrlFound.tsx @@ -2,14 +2,14 @@ import Link from 'next/link'; const NoUrlFound = () => { return ( -
+
Oops! We couldn't find any URLs. Would you like to create one?
Create a URL diff --git a/src/components/Dashboard/UrlListItem.tsx b/src/components/Dashboard/UrlListItem.tsx index 4c71c702..a56a9fe0 100644 --- a/src/components/Dashboard/UrlListItem.tsx +++ b/src/components/Dashboard/UrlListItem.tsx @@ -1,7 +1,7 @@ -import { Tooltip } from '@nextui-org/react'; import Link from 'next/link'; +import { LiaStopwatchSolid } from 'react-icons/lia'; import { MdOutlineContentCopy } from 'react-icons/md'; -import { TbTrash, TbWorldWww } from 'react-icons/tb'; +import { TbTrash } from 'react-icons/tb'; import { useMutation } from 'react-query'; import { TINY_SITE } from '@/constants/url'; @@ -12,100 +12,97 @@ import { UrlType } from '@/types/url.types'; import formatDate from '@/utils/formatDate'; import Button from '../Button'; -import { Loader } from '../Loader'; + +export const DeleteButton: React.FC<{ isLoading: boolean; onDelete: () => void }> = ({ isLoading, onDelete }) => ( + +); + +export const CopyButton: React.FC<{ onCopy: () => void }> = ({ onCopy }) => ( + +); interface UrlListItemProps { url: UrlType; copyButtonHandler: (url: string) => void; } -const UrlListItem = ({ url, copyButtonHandler }: UrlListItemProps) => { +const UrlListItem: React.FC = ({ url, copyButtonHandler }) => { + const { userData } = useAuthenticated(); const deleteMutation = useMutation({ mutationFn: deleteUrlApi, - onSuccess: () => { - queryClient.invalidateQueries(['urls']); - }, + onSuccess: () => queryClient.invalidateQueries(['urls']), onError: (error) => { window.alert('Error deleting URL'); console.error(error); }, }); - const { userData } = useAuthenticated(); - return ( -
  • -
    -
    - -
    + const handleCopy = () => copyButtonHandler(`${TINY_SITE}/${url.shortUrl}`); + const handleDelete = () => deleteMutation.mutate({ id: url.id, userId: Number(userData?.data?.id) }); -
    -
    - - {TINY_SITE}/{url.shortUrl} - - -
    + return ( +
    +
    +
    + + {TINY_SITE}/{url.shortUrl} + {url.originalUrl} - -

    - Created on : {' '} - {formatDate({ - inputDate: url.createdAt as string, - relativeDuration: true, - fullDate: false, - })} -

    -
    +
    +
    + +
    -
    - +
    +
    +

    + + {formatDate({ + inputDate: url.createdAt as string, + relativeDuration: false, + fullDate: true, + })} +

    +
    + + +
    -
  • +
    ); }; diff --git a/src/components/Dashboard/dashboard-layout.tsx b/src/components/Dashboard/dashboard-layout.tsx index 06fdc639..1704e5b8 100644 --- a/src/components/Dashboard/dashboard-layout.tsx +++ b/src/components/Dashboard/dashboard-layout.tsx @@ -9,13 +9,13 @@ const MAX_URLS = 50; export const DashboardLayout = ({ remainingUrls, children }: DashboardLayoutProps) => { return ( -
    +
    -
    -

    Your URLs

    +
    +

    Your URLs

    {remainingUrls !== undefined && ( -

    +

    Remaining: {remainingUrls} / {MAX_URLS}

    )} diff --git a/src/components/Footer/index.tsx b/src/components/Footer/index.tsx index 271a9285..b43a55d3 100644 --- a/src/components/Footer/index.tsx +++ b/src/components/Footer/index.tsx @@ -3,7 +3,7 @@ import React from 'react'; const Footer: React.FC = () => { return ( -