diff --git a/src/components/Auth/__tests__/index.tsx b/src/components/Auth/__tests__/index.tsx index cf0076ef1..7ce03854a 100644 --- a/src/components/Auth/__tests__/index.tsx +++ b/src/components/Auth/__tests__/index.tsx @@ -176,6 +176,37 @@ describe('Auth Component', () => { }) }) + 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()] 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