diff --git a/src/components/AppContainer/index.tsx b/src/components/AppContainer/index.tsx index a9678ab2d..5fb15f96b 100644 --- a/src/components/AppContainer/index.tsx +++ b/src/components/AppContainer/index.tsx @@ -1,5 +1,6 @@ import { lazy, Suspense } from 'react' import { Route, Routes } from 'react-router-dom' + import { E2ETests } from '~/utils' import { AppProviders } from '../App/Providers' import { AuthGuard } from '../Auth' diff --git a/src/components/Auth/__tests__/index.tsx b/src/components/Auth/__tests__/index.tsx index cf0076ef1..402f3da88 100644 --- a/src/components/Auth/__tests__/index.tsx +++ b/src/components/Auth/__tests__/index.tsx @@ -1,335 +1,366 @@ -import { ThemeProvider } from '@mui/material' -import '@testing-library/jest-dom' -import { cleanup, render, screen, waitFor } from '@testing-library/react' -import { setupJestCanvasMock } from 'jest-canvas-mock' -import React from 'react' -import { MemoryRouter } from 'react-router-dom' -import * as sphinx from 'sphinx-bridge' -import { ThemeProvider as StyleThemeProvider } from 'styled-components' -import * as network from '../../../network/auth' -import { useDataStore } from '../../../stores/useDataStore' -import { useUserStore } from '../../../stores/useUserStore' -import * as utils from '../../../utils/getSignedMessage' -import { App } from '../../App' -import { appTheme } from '../../App/Providers' -import { AuthGuard } from '../index' - -jest.mock('sphinx-bridge') -jest.mock('~/stores/useUserStore') -jest.mock('~/stores/useDataStore') -jest.mock('~/utils/versionHelper', () => null) -jest.mock('react-toastify/dist/ReactToastify.css', () => null) -jest.mock('~/components/App/Splash/SpiningSphere', () => jest.fn(() =>
)) - -jest.mock('~/components/Universe', () => ({ - Universe: () =>
Mocked Universe Component
, -})) - -Object.defineProperty(navigator, 'userAgent', { - value: 'Sphinx', - configurable: true, -}) - -const useDataStoreMock = useDataStore as jest.MockedFunction -const useUserStoreMock = useUserStore as jest.MockedFunction -const getSignedMessageFromRelayMock = jest.spyOn(utils, 'getSignedMessageFromRelay') -const getIsAdminMock = jest.spyOn(network, 'getIsAdmin') - -const message = 'This is a private Graph, Contact Admin' - -describe('Auth Component', () => { - afterEach(cleanup) - - beforeAll(() => { - jest.clearAllMocks() - localStorage.clear() - sessionStorage.clear() - }) - - beforeEach(() => { - localStorage.clear() - jest.resetAllMocks() - setupJestCanvasMock(window) - - useDataStoreMock.mockReturnValue({ - fetchData: jest.fn(), - setCategoryFilter: jest.fn(), - setAbortRequests: jest.fn(), - addNewNode: jest.fn(), - splashDataLoading: false, - }) - - Object.defineProperty(window, 'matchMedia', { - writable: true, - value: jest.fn().mockImplementation(() => ({ - matches: false, - removeEventListener: jest.fn(), - })), - }) - }) - - test('should set authenticated state to true upon successful authentication', async () => { - const [setBudget, setIsAdmin, setPubKey, setIsAuthenticated] = [jest.fn(), jest.fn(), jest.fn(), jest.fn()] - - useUserStoreMock.mockReturnValue({ - setBudget, - setIsAdmin, - setPubKey, - setIsAuthenticated, - }) - - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - sphinx.enable.mockResolvedValue() - getIsAdminMock.mockResolvedValue({ data: { isAdmin: false, isPublic: true } }) - getSignedMessageFromRelayMock.mockResolvedValue({ message: 'testMessage', signature: 'testSignature' }) - - render( - - - - - - - - - , - ) - - await waitFor(() => expect(setIsAuthenticated).toHaveBeenCalledWith(true)) - }, 50000) - - test('should update appropriate state and local storage if user is an admin', async () => { - const [setBudget, setIsAdmin, setPubKey, setIsAuthenticated] = [jest.fn(), jest.fn(), jest.fn(), jest.fn()] - - useUserStoreMock.mockReturnValue({ - isAdmin: true, - setBudget, - setIsAdmin, - setPubKey, - setIsAuthenticated, - }) - - getIsAdminMock.mockResolvedValue({ data: { isAdmin: true } }) - getSignedMessageFromRelayMock.mockResolvedValue({ message: 'testMessage', signature: 'testSignature' }) - - render( - - - - - - - - - , - ) - - await waitFor(() => expect(setIsAdmin).toHaveBeenCalledWith(true)) - await waitFor(() => expect(localStorage.getItem('admin')).not.toBeNull()) - }) - - test('should render the unauthorized access message if user is not authorized', async () => { - const [setBudget, setIsAdmin, setPubKey, setIsAuthenticated] = [jest.fn(), jest.fn(), jest.fn(), jest.fn()] - - useUserStoreMock.mockReturnValue({ - setBudget, - setIsAdmin, - setPubKey, - setIsAuthenticated, - }) - - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - sphinx.enable.mockResolvedValue({ pubkey: 'testPubkey' }) - - getIsAdminMock.mockRejectedValue({ - response: { - status: 401, - data: { - status: 'error', - message: 'Permission denied', - }, - }, - }) - - getSignedMessageFromRelayMock.mockResolvedValue({ message: 'testMessage', signature: 'testSignature' }) - - render( - - - - - - - - - , - ) - - await waitFor(() => { - expect(getIsAdminMock).toHaveBeenCalled() - }) - - await waitFor(() => { - expect(screen.getByText(message)).toBeInTheDocument() - }) - }) - - test.skip('the unauthorized state is correctly set when the user lacks proper credentials', async () => { - const [setBudget, setIsAdmin, setPubKey, setIsAuthenticated] = [jest.fn(), jest.fn(), jest.fn(), jest.fn()] - - useUserStoreMock.mockReturnValue({ - setBudget, - setIsAdmin, - setPubKey, - setIsAuthenticated, - }) - - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - sphinx.enable.mockResolvedValue({ pubkey: 'testPubKey' }) - getIsAdminMock.mockResolvedValue({ data: { isAdmin: false, isPublic: true, isMember: false } }) - getSignedMessageFromRelayMock.mockResolvedValue({ message: 'testMessage', signature: '' }) - - render( - - - - - - - - - , - ) - - await waitFor(() => expect(setIsAuthenticated).toHaveBeenCalledWith(true)) - }) - - test('test unsuccessful attempts to enable Sphinx', async () => { - const [setBudget, setIsAdmin, setPubKey, setIsAuthenticated] = [jest.fn(), jest.fn(), jest.fn(), jest.fn()] - - useUserStoreMock.mockReturnValue({ - setBudget, - setIsAdmin, - setPubKey, - setIsAuthenticated, - }) - - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - sphinx.enable.mockResolvedValue(null) - getIsAdminMock.mockResolvedValue({ data: { isAdmin: false, isPublic: true, isMember: false } }) - getSignedMessageFromRelayMock.mockResolvedValue({ message: 'testMessage', signature: '' }) - - render( - - - - - - - - - , - ) - - await waitFor(() => expect(setPubKey).toHaveBeenCalledWith(undefined)) - await waitFor(() => expect(setIsAuthenticated).toHaveBeenCalledWith(true)) - }) - - test('test the public key is set correctly on successful Sphinx enablement', async () => { - const [setBudget, setIsAdmin, setPubKey, setIsAuthenticated] = [jest.fn(), jest.fn(), jest.fn(), jest.fn()] - - useUserStoreMock.mockReturnValue({ - setBudget, - setIsAdmin, - setPubKey, - setIsAuthenticated, - }) - - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - sphinx.enable.mockResolvedValue({ pubkey: 'testPubkey' }) - getIsAdminMock.mockResolvedValue({ data: { isAdmin: false, isPublic: true, isMember: false } }) - getSignedMessageFromRelayMock.mockResolvedValue({ message: 'testMessage', signature: '' }) - - render( - - - - - - - - - , - ) - - await waitFor(() => expect(setPubKey).toHaveBeenCalledWith('testPubkey')) - }) - - test('test the public key state is handled correctly on Sphinx enablement failure', async () => { - const [setBudget, setIsAdmin, setPubKey, setIsAuthenticated] = [jest.fn(), jest.fn(), jest.fn(), jest.fn()] - - useUserStoreMock.mockReturnValue({ - setBudget, - setIsAdmin, - setPubKey, - setIsAuthenticated, - }) - - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - sphinx.enable.mockRejectedValue() - getIsAdminMock.mockResolvedValue({ data: { isAdmin: false, isPublic: true, isMember: false } }) - getSignedMessageFromRelayMock.mockResolvedValue({ message: 'testMessage', signature: '' }) - - render( - - - - - - - - - , - ) - - await waitFor(() => expect(setPubKey).toHaveBeenCalledWith('')) - }) - - test('simulate errors during the authentication process and verify that they are handled gracefully.', async () => { - const [setBudget, setIsAdmin, setPubKey, setIsAuthenticated] = [jest.fn(), jest.fn(), jest.fn(), jest.fn()] - - useUserStoreMock.mockReturnValue({ - setBudget, - setIsAdmin, - setPubKey, - setIsAuthenticated, - }) - - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - sphinx.enable.mockRejectedValue() - getIsAdminMock.mockResolvedValue({ data: { isAdmin: false, isPublic: true, isMember: false } }) - getSignedMessageFromRelayMock.mockRejectedValue(null) - - render( - - - - - - - - - , - ) - - await waitFor(() => expect(setPubKey).toHaveBeenCalledWith('')) - await waitFor(() => expect(setIsAuthenticated).toHaveBeenCalledWith(true)) - }) -}) +import { ThemeProvider } from '@mui/material' +import '@testing-library/jest-dom' +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import { setupJestCanvasMock } from 'jest-canvas-mock' +import React from 'react' +import { MemoryRouter } from 'react-router-dom' +import * as sphinx from 'sphinx-bridge' +import { ThemeProvider as StyleThemeProvider } from 'styled-components' +import * as network from '../../../network/auth' +import { useDataStore } from '../../../stores/useDataStore' +import { useUserStore } from '../../../stores/useUserStore' +import * as utils from '../../../utils/getSignedMessage' +import { App } from '../../App' +import { appTheme } from '../../App/Providers' +import { AuthGuard } from '../index' + +jest.mock('sphinx-bridge') +jest.mock('~/stores/useUserStore') +jest.mock('~/stores/useDataStore') +jest.mock('~/utils/versionHelper', () => null) +jest.mock('react-toastify/dist/ReactToastify.css', () => null) +jest.mock('~/components/App/Splash/SpiningSphere', () => jest.fn(() =>
)) + +jest.mock('~/components/Universe', () => ({ + Universe: () =>
Mocked Universe Component
, +})) + +Object.defineProperty(navigator, 'userAgent', { + value: 'Sphinx', + configurable: true, +}) + +const useDataStoreMock = useDataStore as jest.MockedFunction +const useUserStoreMock = useUserStore as jest.MockedFunction +const getSignedMessageFromRelayMock = jest.spyOn(utils, 'getSignedMessageFromRelay') +const getIsAdminMock = jest.spyOn(network, 'getIsAdmin') + +const message = 'This is a private Graph, Contact Admin' + +describe('Auth Component', () => { + afterEach(cleanup) + + beforeAll(() => { + jest.clearAllMocks() + localStorage.clear() + sessionStorage.clear() + }) + + beforeEach(() => { + localStorage.clear() + jest.resetAllMocks() + setupJestCanvasMock(window) + + useDataStoreMock.mockReturnValue({ + fetchData: jest.fn(), + setCategoryFilter: jest.fn(), + setAbortRequests: jest.fn(), + addNewNode: jest.fn(), + splashDataLoading: false, + }) + + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation(() => ({ + matches: false, + removeEventListener: jest.fn(), + })), + }) + }) + + test('should set authenticated state to true upon successful authentication', async () => { + const [setBudget, setIsAdmin, setPubKey, setIsAuthenticated] = [jest.fn(), jest.fn(), jest.fn(), jest.fn()] + + useUserStoreMock.mockReturnValue({ + setBudget, + setIsAdmin, + setPubKey, + setIsAuthenticated, + }) + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + sphinx.enable.mockResolvedValue() + getIsAdminMock.mockResolvedValue({ data: { isAdmin: false, isPublic: true } }) + getSignedMessageFromRelayMock.mockResolvedValue({ message: 'testMessage', signature: 'testSignature' }) + + render( + + + + + + + + + , + ) + + await waitFor(() => expect(setIsAuthenticated).toHaveBeenCalledWith(true)) + }, 50000) + + test('should update appropriate state and local storage if user is an admin', async () => { + const [setBudget, setIsAdmin, setPubKey, setIsAuthenticated] = [jest.fn(), jest.fn(), jest.fn(), jest.fn()] + + useUserStoreMock.mockReturnValue({ + isAdmin: true, + setBudget, + setIsAdmin, + setPubKey, + setIsAuthenticated, + }) + + getIsAdminMock.mockResolvedValue({ data: { isAdmin: true } }) + getSignedMessageFromRelayMock.mockResolvedValue({ message: 'testMessage', signature: 'testSignature' }) + + render( + + + + + + + + + , + ) + + await waitFor(() => expect(setIsAdmin).toHaveBeenCalledWith(true)) + await waitFor(() => expect(localStorage.getItem('admin')).not.toBeNull()) + }) + + test('should render the unauthorized access message if user is not authorized', async () => { + const [setBudget, setIsAdmin, setPubKey, setIsAuthenticated] = [jest.fn(), jest.fn(), jest.fn(), jest.fn()] + + useUserStoreMock.mockReturnValue({ + setBudget, + setIsAdmin, + setPubKey, + setIsAuthenticated, + }) + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + sphinx.enable.mockResolvedValue({ pubkey: 'testPubkey' }) + + getIsAdminMock.mockRejectedValue({ + response: { + status: 401, + data: { + status: 'error', + message: 'Permission denied', + }, + }, + }) + + getSignedMessageFromRelayMock.mockResolvedValue({ message: 'testMessage', signature: 'testSignature' }) + + render( + + + + + + + + + , + ) + + await waitFor(() => { + expect(getIsAdminMock).toHaveBeenCalled() + }) + + await waitFor(() => { + expect(screen.getByText(message)).toBeInTheDocument() + }) + }) + + test('should show onboarding modal if admin and no title is set', async () => { + const [setBudget, setIsAdmin, setPubKey, setIsAuthenticated] = [jest.fn(), jest.fn(), jest.fn(), jest.fn()] + + useUserStoreMock.mockReturnValue({ + isAdmin: true, + setBudget, + setIsAdmin, + setPubKey, + setIsAuthenticated, + }) + + getIsAdminMock.mockResolvedValue({ data: { isAdmin: true, title: null } }) + getSignedMessageFromRelayMock.mockResolvedValue({ message: 'testMessage', signature: 'testSignature' }) + + render( + + + + + + + + + , + ) + + waitFor(() => { + expect(screen.getByText('Welcome to SecondBrain')).toBeInTheDocument() + }) + }) + + test.skip('the unauthorized state is correctly set when the user lacks proper credentials', async () => { + const [setBudget, setIsAdmin, setPubKey, setIsAuthenticated] = [jest.fn(), jest.fn(), jest.fn(), jest.fn()] + + useUserStoreMock.mockReturnValue({ + setBudget, + setIsAdmin, + setPubKey, + setIsAuthenticated, + }) + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + sphinx.enable.mockResolvedValue({ pubkey: 'testPubKey' }) + getIsAdminMock.mockResolvedValue({ data: { isAdmin: false, isPublic: true, isMember: false } }) + getSignedMessageFromRelayMock.mockResolvedValue({ message: 'testMessage', signature: '' }) + + render( + + + + + + + + + , + ) + + await waitFor(() => expect(setIsAuthenticated).toHaveBeenCalledWith(true)) + }) + + test('test unsuccessful attempts to enable Sphinx', async () => { + const [setBudget, setIsAdmin, setPubKey, setIsAuthenticated] = [jest.fn(), jest.fn(), jest.fn(), jest.fn()] + + useUserStoreMock.mockReturnValue({ + setBudget, + setIsAdmin, + setPubKey, + setIsAuthenticated, + }) + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + sphinx.enable.mockResolvedValue(null) + getIsAdminMock.mockResolvedValue({ data: { isAdmin: false, isPublic: true, isMember: false } }) + getSignedMessageFromRelayMock.mockResolvedValue({ message: 'testMessage', signature: '' }) + + render( + + + + + + + + + , + ) + + await waitFor(() => expect(setPubKey).toHaveBeenCalledWith(undefined)) + await waitFor(() => expect(setIsAuthenticated).toHaveBeenCalledWith(true)) + }) + + test('test the public key is set correctly on successful Sphinx enablement', async () => { + const [setBudget, setIsAdmin, setPubKey, setIsAuthenticated] = [jest.fn(), jest.fn(), jest.fn(), jest.fn()] + + useUserStoreMock.mockReturnValue({ + setBudget, + setIsAdmin, + setPubKey, + setIsAuthenticated, + }) + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + sphinx.enable.mockResolvedValue({ pubkey: 'testPubkey' }) + getIsAdminMock.mockResolvedValue({ data: { isAdmin: false, isPublic: true, isMember: false } }) + getSignedMessageFromRelayMock.mockResolvedValue({ message: 'testMessage', signature: '' }) + + render( + + + + + + + + + , + ) + + await waitFor(() => expect(setPubKey).toHaveBeenCalledWith('testPubkey')) + }) + + test('test the public key state is handled correctly on Sphinx enablement failure', async () => { + const [setBudget, setIsAdmin, setPubKey, setIsAuthenticated] = [jest.fn(), jest.fn(), jest.fn(), jest.fn()] + + useUserStoreMock.mockReturnValue({ + setBudget, + setIsAdmin, + setPubKey, + setIsAuthenticated, + }) + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + sphinx.enable.mockRejectedValue() + getIsAdminMock.mockResolvedValue({ data: { isAdmin: false, isPublic: true, isMember: false } }) + getSignedMessageFromRelayMock.mockResolvedValue({ message: 'testMessage', signature: '' }) + + render( + + + + + + + + + , + ) + + await waitFor(() => expect(setPubKey).toHaveBeenCalledWith('')) + }) + + test('simulate errors during the authentication process and verify that they are handled gracefully.', async () => { + const [setBudget, setIsAdmin, setPubKey, setIsAuthenticated] = [jest.fn(), jest.fn(), jest.fn(), jest.fn()] + + useUserStoreMock.mockReturnValue({ + setBudget, + setIsAdmin, + setPubKey, + setIsAuthenticated, + }) + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + sphinx.enable.mockRejectedValue() + getIsAdminMock.mockResolvedValue({ data: { isAdmin: false, isPublic: true, isMember: false } }) + getSignedMessageFromRelayMock.mockRejectedValue(null) + + render( + + + + + + + + + , + ) + + await waitFor(() => expect(setPubKey).toHaveBeenCalledWith('')) + await waitFor(() => expect(setIsAuthenticated).toHaveBeenCalledWith(true)) + }) +}) diff --git a/src/components/Auth/index.tsx b/src/components/Auth/index.tsx index 540514c5e..5fce6b550 100644 --- a/src/components/Auth/index.tsx +++ b/src/components/Auth/index.tsx @@ -3,6 +3,7 @@ import * as sphinx from 'sphinx-bridge' import styled from 'styled-components' import { Flex } from '~/components/common/Flex' import { Text } from '~/components/common/Text' +import { OnboardingModal } from '~/components/ModalsContainer/OnboardingFlow' import { isDevelopment, isE2E } from '~/constants' import { getIsAdmin } from '~/network/auth' import { useDataStore } from '~/stores/useDataStore' @@ -18,6 +19,7 @@ export const AuthGuard = ({ children }: PropsWithChildren) => { const { setBudget, setIsAdmin, setPubKey, setIsAuthenticated, setSwarmUiUrl } = useUserStore((s) => s) const { splashDataLoading } = useDataStore((s) => s) const [renderMainPage, setRenderMainPage] = useState(false) + const [showOnboarding, setShowOnboarding] = useState(false) const { setTrendingTopicsFeatureFlag, @@ -75,6 +77,10 @@ export const AuthGuard = ({ children }: PropsWithChildren) => { setRealtimeGraphFeatureFlag(res.data.realtimeGraph || false) setChatInterfaceFeatureFlag(res.data.chatInterface || false) setFastFiltersFeatureFlag(res.data.fastFilters || false) + + if (isAdmin && !res.data.title) { + setShowOnboarding(true) + } } setIsAuthenticated(true) @@ -131,6 +137,7 @@ export const AuthGuard = ({ children }: PropsWithChildren) => { return ( <> + {showOnboarding && } {splashDataLoading && } {renderMainPage && children} diff --git a/src/components/ModalsContainer/OnboardingFlow/GraphDetailsStep/index.tsx b/src/components/ModalsContainer/OnboardingFlow/GraphDetailsStep/index.tsx new file mode 100644 index 000000000..007ba80ac --- /dev/null +++ b/src/components/ModalsContainer/OnboardingFlow/GraphDetailsStep/index.tsx @@ -0,0 +1,164 @@ +import { Button } from '@mui/material' +import { FC, useEffect } from 'react' +import { useFormContext } from 'react-hook-form' +import { MdError } from 'react-icons/md' +import styled from 'styled-components' +import { noSpacePattern } from '~/components/AddItemModal/SourceTypeStep/constants' +import { Flex } from '~/components/common/Flex' +import { Text } from '~/components/common/Text' +import { TextInput } from '~/components/common/TextInput' +import { requiredRule } from '~/constants' +import { colors } from '~/utils' + +type Props = { + onSubmit: () => void + error?: string +} + +export const GraphDetailsStep: FC = ({ onSubmit, error }) => { + const { + formState: { isSubmitting }, + watch, + } = useFormContext() + + const title = watch('title') + const description = watch('description') + + const isFormValid = !!title?.trim() && !!description?.trim() + + useEffect(() => { + const titleInput = document.getElementById('graph-title') as HTMLInputElement + + if (titleInput) { + titleInput.focus() + } + }, []) + + return ( + + + Welcome to SecondBrain + Set a name and short description for your graph. + + + + + + + + + + + + + {error ? ( + + + + {error} + + + ) : null} + + ) +} + +const StyledText = styled(Text)` + font-size: 22px; + font-weight: 600; + font-family: 'Barlow'; + margin-bottom: 10px; +` + +const StyledSubText = styled(Text)` + font-size: 14px; + font-family: 'Barlow'; + margin-bottom: 20px; +` + +const StyledWrapper = styled(Flex)` + width: 100%; + display: flex; + justify-content: center; + gap: 10px; + margin: 0 0 15px 0; + + .input__wrapper { + display: flex; + gap: 23px; + max-height: 225px; + overflow-y: auto; + padding-right: 20px; + width: calc(100% + 20px); + } +` + +const StyledErrorText = styled(Flex)` + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + gap: 2px; + + .errorIcon { + display: block; + font-size: 13px; + min-height: 13px; + min-width: 13px; + } + + span { + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + overflow: hidden; + white-space: normal; + letter-spacing: 0.2px; + padding-left: 4px; + font-size: 13px; + font-family: Barlow; + line-height: 18px; + } +` + +const StyledError = styled(Flex)` + display: flex; + align-items: center; + color: ${colors.primaryRed}; + position: relative; + margin-top: 20px; +` diff --git a/src/components/ModalsContainer/OnboardingFlow/__tests__/index.tsx b/src/components/ModalsContainer/OnboardingFlow/__tests__/index.tsx new file mode 100644 index 000000000..520e1c7c2 --- /dev/null +++ b/src/components/ModalsContainer/OnboardingFlow/__tests__/index.tsx @@ -0,0 +1,120 @@ +import '@testing-library/jest-dom' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import React from 'react' +import { postAboutData } from '~/network/fetchSourcesData' +import { useModal } from '~/stores/useModalStore' +import { OnboardingModal } from '../index' + +jest.mock('~/network/fetchSourcesData', () => ({ + postAboutData: jest.fn(), +})) + +jest.mock('~/stores/useModalStore', () => ({ + useModal: jest.fn(), +})) + +const useModalMock = useModal as jest.MockedFunction +const postAboutDataMock = postAboutData as jest.MockedFunction + +describe('OnboardingModal Component', () => { + beforeEach(() => { + jest.clearAllMocks() + + useModalMock.mockReturnValue({ + close: jest.fn(), + visible: true, + }) + }) + + test('renders the onboarding modal', () => { + render() + expect(screen.getByText('Welcome to SecondBrain')).toBeInTheDocument() + expect(screen.getByText('Set a name and short description for your graph.')).toBeInTheDocument() + }) + + test('submits form successfully', async () => { + postAboutDataMock.mockResolvedValue({ status: 'success' }) + + render() + + fireEvent.change(screen.getByPlaceholderText('Type graph title here...'), { target: { value: 'Test Title' } }) + + fireEvent.change(screen.getByPlaceholderText('Type graph description here...'), { + target: { value: 'Test Description' }, + }) + + fireEvent.click(screen.getByText('Confirm')) + + await waitFor(() => { + expect(postAboutDataMock).toHaveBeenCalledWith({ + title: 'Test Title', + description: 'Test Description', + }) + }) + }) + + test('displays error on form submission failure', async () => { + postAboutDataMock.mockRejectedValue({ status: 400, json: async () => ({ errorCode: 'Error occurred' }) }) + + render() + + fireEvent.change(screen.getByPlaceholderText('Type graph title here...'), { target: { value: 'Test Title' } }) + + fireEvent.change(screen.getByPlaceholderText('Type graph description here...'), { + target: { value: 'Test Description' }, + }) + + fireEvent.click(screen.getByText('Confirm')) + + await waitFor(() => { + expect(screen.getByText('Error occurred')).toBeInTheDocument() + }) + }) + + test('closes modal on successful submission', async () => { + const closeMock = jest.fn() + + useModalMock.mockReturnValue({ + close: closeMock, + visible: true, + }) + + postAboutDataMock.mockResolvedValue({ status: 'success' }) + + render() + + fireEvent.change(screen.getByPlaceholderText('Type graph title here...'), { target: { value: 'Test Title' } }) + + fireEvent.change(screen.getByPlaceholderText('Type graph description here...'), { + target: { value: 'Test Description' }, + }) + + fireEvent.click(screen.getByText('Confirm')) + + await waitFor(() => { + expect(closeMock).toHaveBeenCalled() + }) + }) + + test('resets form and error on modal close', async () => { + const { rerender } = render() + + fireEvent.change(screen.getByPlaceholderText('Type graph title here...'), { target: { value: 'Test Title' } }) + + fireEvent.change(screen.getByPlaceholderText('Type graph description here...'), { + target: { value: 'Test Description' }, + }) + + useModalMock.mockReturnValue({ + close: jest.fn(), + visible: false, + }) + + rerender() + + waitFor(() => { + expect(screen.getByPlaceholderText('Type graph title here...')).toHaveValue('') + expect(screen.getByPlaceholderText('Type graph description here...')).toHaveValue('') + }) + }) +}) diff --git a/src/components/ModalsContainer/OnboardingFlow/index.tsx b/src/components/ModalsContainer/OnboardingFlow/index.tsx new file mode 100644 index 000000000..2d5123381 --- /dev/null +++ b/src/components/ModalsContainer/OnboardingFlow/index.tsx @@ -0,0 +1,75 @@ +import { useEffect, useState } from 'react' +import { FormProvider, useForm } from 'react-hook-form' +import { SuccessNotify } from '~/components/common/SuccessToast' +import { BaseModal } from '~/components/Modal' +import { NODE_ADD_ERROR } from '~/constants' +import { postAboutData, TAboutParams } from '~/network/fetchSourcesData' +import { useModal } from '~/stores/useModalStore' +import { GraphDetailsStep } from './GraphDetailsStep' + +export type FormData = { + title: string + description: string +} + +export const OnboardingModal = () => { + const { close, visible } = useModal('onboardingFlow') + const form = useForm({ mode: 'onChange' }) + const { reset } = form + const [error, setError] = useState('') + + useEffect(() => { + if (!visible) { + reset() + setError('') + } + }, [visible, reset]) + + const submitGraphDetails = async ( + data: TAboutParams, + successCallback: () => void, + onError: (error: string) => void, + ) => { + try { + const res = (await postAboutData(data)) as Awaited<{ status: string }> + + if (res.status === 'success') { + SuccessNotify('Graph details saved') + successCallback() + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (err: any) { + let errorMessage = NODE_ADD_ERROR + + if (err?.status === 400) { + const errorRes = await err.json() + + errorMessage = errorRes.errorCode || errorRes?.status || NODE_ADD_ERROR + } else if (err instanceof Error) { + errorMessage = err.message + } + + onError(String(errorMessage)) + } + } + + const onSubmit = form.handleSubmit(async (data) => { + await submitGraphDetails( + data, + () => { + close() + }, + setError, + ) + }) + + return ( + + +
+ + +
+
+ ) +} diff --git a/src/components/ModalsContainer/index.tsx b/src/components/ModalsContainer/index.tsx index 664489b77..c40fd3a60 100644 --- a/src/components/ModalsContainer/index.tsx +++ b/src/components/ModalsContainer/index.tsx @@ -46,6 +46,10 @@ const LazyCreateBountyModal = lazy(() => import('./CreateBountyModal').then(({ CreateBountyModal }) => ({ default: CreateBountyModal })), ) +const LazyOnboardingModal = lazy(() => + import('./OnboardingFlow').then(({ OnboardingModal }) => ({ default: OnboardingModal })), +) + export const ModalsContainer = () => ( <> @@ -60,5 +64,6 @@ export const ModalsContainer = () => ( + ) diff --git a/src/stores/useModalStore/index.ts b/src/stores/useModalStore/index.ts index 5dbf17182..b9efb3681 100644 --- a/src/stores/useModalStore/index.ts +++ b/src/stores/useModalStore/index.ts @@ -21,6 +21,7 @@ export type AvailableModals = | 'changeNodeType' | 'feedback' | 'createBounty' + | 'onboardingFlow' type ModalStore = { currentModals: Record @@ -51,6 +52,7 @@ const defaultData = { changeNodeType: false, feedback: false, createBounty: false, + onboardingFlow: false, }, } diff --git a/src/types/index.ts b/src/types/index.ts index bab9fe772..152948948 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -242,6 +242,7 @@ export type IsAdminResponse = { chatInterface: boolean swarmUiUrl: string fastFilters: boolean + title: string } success: boolean message: string