Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion apps/console-v5/src/app/components/header/header.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Link, useParams } from '@tanstack/react-router'
import posthog from 'posthog-js'
import { useFeatureFlagVariantKey } from 'posthog-js/react'
import { Suspense, useCallback } from 'react'
Expand All @@ -21,6 +22,7 @@ export function Separator() {
}

export function Header() {
const { organizationId = '' } = useParams({ strict: false })
const isDevopsCopilotEnabled = useFeatureFlagVariantKey('devops-copilot')
const handleFeedbackClick = useCallback(() => {
posthog.capture('feedback_button_clicked_new_navigation')
Expand All @@ -30,7 +32,9 @@ export function Header() {
<header className="relative z-header w-full bg-background-secondary py-4 pl-3 pr-4">
<div className="flex items-center gap-3 md:gap-4">
<div className="flex shrink-0 items-center gap-4">
<LogoIcon />
<Link to="/organization/$organizationId/overview" params={{ organizationId }}>
<LogoIcon />
</Link>
<Separator />
</div>
<Suspense fallback={<div className="h-8 flex-1" />}>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,28 +1,64 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useEffect } from 'react'
import { useEffect, useMemo } from 'react'
import { z } from 'zod'
import { AcceptInvitation, useInviteMember } from '@qovery/domains/onboarding/feature'

const acceptInvitationSearchSchema = z.object({
inviteToken: z.string().optional(),
organization: z.string().optional(),
})

export const Route = createFileRoute('/_authenticated/accept-invitation/')({
component: RouteComponent,
validateSearch: acceptInvitationSearchSchema,
component: AcceptInvitationRouteComponent,
})

function RouteComponent() {
const { acceptInvitation, displayInvitation, checkTokenInStorage } = useInviteMember()
export function AcceptInvitationRouteComponent() {
const {
acceptInvitation,
displayInvitation,
fetchInvitationDetail,
initializeInvitation,
inviteDetail,
isAcceptingInvitation,
} = useInviteMember()
const navigate = useNavigate()
const search = Route.useSearch()

const inviteSearch = useMemo(() => {
const searchParams = new URLSearchParams()

if (search.inviteToken) {
searchParams.set('inviteToken', search.inviteToken)
}

if (search.organization) {
searchParams.set('organization', search.organization)
}

const searchString = searchParams.toString()
return searchString ? `?${searchString}` : ''
}, [search.inviteToken, search.organization])

useEffect(() => {
checkTokenInStorage()
}, [checkTokenInStorage])
initializeInvitation(inviteSearch)
}, [initializeInvitation, inviteSearch])

const onSubmit = async () => {
await acceptInvitation()
}

useEffect(() => {
if (displayInvitation) {
fetchInvitationDetail().then()
}
}, [displayInvitation, fetchInvitationDetail])

useEffect(() => {
if (displayInvitation === false) {
navigate({ to: '/login' })
}
}, [displayInvitation, navigate])

return <AcceptInvitation onSubmit={onSubmit} />
return <AcceptInvitation inviteDetail={inviteDetail} loading={isAcceptingInvitation} onSubmit={onSubmit} />
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,37 @@
import { render } from '__tests__/utils/setup-jest'
import { type InviteMember } from 'qovery-typescript-axios'
import { renderWithProviders, screen } from '@qovery/shared/util-tests'
import AcceptInvitation from './accept-invitation'

jest.mock('@tanstack/react-router', () => ({
...jest.requireActual('@tanstack/react-router'),
useLocation: () => ({ pathname: '/accept-invitation', search: '' }),
useNavigate: () => jest.fn(),
}))

describe('AcceptInvitation', () => {
it('should render successfully', () => {
const { baseElement } = render(<AcceptInvitation onSubmit={jest.fn()} />)
const { baseElement } = renderWithProviders(<AcceptInvitation onSubmit={jest.fn()} />)
expect(baseElement).toBeTruthy()
})

it('should render invitation details from props', () => {
renderWithProviders(
<AcceptInvitation
onSubmit={jest.fn()}
inviteDetail={{ inviter: 'Jane', organization_name: 'Qovery' } as InviteMember}
/>
)

expect(screen.getByText('Jane has invited you to join:')).toBeInTheDocument()
expect(screen.getByText('Qovery')).toBeInTheDocument()
})

it('should call onSubmit when clicking accept', async () => {
const onSubmit = jest.fn()
const { userEvent } = renderWithProviders(<AcceptInvitation onSubmit={onSubmit} />)

await userEvent.click(screen.getByRole('button', { name: 'Accept invitation' }))

expect(onSubmit).toHaveBeenCalledTimes(1)
})

it('should show a loading state on the accept button', () => {
renderWithProviders(<AcceptInvitation loading onSubmit={jest.fn()} />)

expect(screen.getByTestId('spinner')).toBeInTheDocument()
})
})
Original file line number Diff line number Diff line change
@@ -1,47 +1,37 @@
import { useEffect } from 'react'
import { type InviteMember } from 'qovery-typescript-axios'
import { Button, LogoBrandedIcon } from '@qovery/shared/ui'
import { useInviteMember } from '../hooks/use-invite-member/use-invite-member'

export interface AcceptInvitationProps {
inviteDetail?: InviteMember
loading?: boolean
onSubmit: () => void
}

interface InviteDetailsProps {
user_name?: string
organization_name?: string
}

function InviteDetails(props: InviteDetailsProps) {
const { user_name = '', organization_name = '' } = props

function InviteDetails({ user_name = '', organization_name = '' }: InviteDetailsProps) {
return (
<p className="text-xl font-bold text-neutral">
{user_name} has invited you to join
<div className="text-xl font-bold text-neutral">
<span>{user_name} has invited you to join: </span>
<br />
<strong className="text-2xl">{organization_name}</strong>
</p>
<strong className="text-brand">{organization_name}</strong>
</div>
)
}

export function AcceptInvitation(props: AcceptInvitationProps) {
const { inviteDetail, fetchInvitationDetail, checkTokenInStorage } = useInviteMember()

useEffect(() => {
checkTokenInStorage()
}, [checkTokenInStorage])

useEffect(() => {
fetchInvitationDetail().then()
}, [fetchInvitationDetail])

export function AcceptInvitation({ inviteDetail, loading = false, onSubmit }: AcceptInvitationProps) {
return (
<div className="bg-neutral fixed inset-0 pt-7">
<LogoBrandedIcon className="mx-auto mb-12 block w-[207px] shrink-0 text-neutral" />
<div className="mx-auto max-w-[568px] rounded-xl bg-surface-neutral-component p-6 text-center">
<div className="bg-neutral fixed inset-0 px-8">
<div className="absolute left-1/2 top-1/2 w-full -translate-x-1/2 -translate-y-1/2 rounded-xl border border-neutral bg-surface-neutral-subtle p-6 text-center shadow-lg md:max-w-[568px]">
<LogoBrandedIcon className="mx-auto block w-[207px] shrink-0 text-neutral" />
{inviteDetail && (
<InviteDetails user_name={inviteDetail.inviter} organization_name={inviteDetail.organization_name} />
)}
<Button type="button" size="lg" className="mt-2 w-full justify-center" onClick={() => props.onSubmit()}>
Accept
<Button type="button" size="lg" className="mt-10 w-full justify-center" loading={loading} onClick={onSubmit}>
Accept invitation
</Button>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,56 +1,102 @@
import { Wrapper } from '__tests__/utils/providers'
import { ACCEPT_INVITATION_URL, LOGIN_URL } from '@qovery/shared/routes'
import { type PropsWithChildren } from 'react'
import { act, renderHook } from '@qovery/shared/util-tests'
import { useInviteMember } from './use-invite-member'

const mockUseNavigate = jest.fn()
const mockUseLocation = jest.fn(() => ({ pathname: '/', search: '' }))
const mockUseMatchRoute = jest.fn()
const mockRefetchMemberInvitation = jest.fn()
const mockRefetchOrganizations = jest.fn()

jest.mock('@tanstack/react-router', () => ({
...jest.requireActual('@tanstack/react-router'),
useNavigate: () => mockUseNavigate,
useLocation: () => mockUseLocation(),
useMatchRoute: () => mockUseMatchRoute,
}))

jest.mock('@qovery/domains/organizations/feature', () => ({
useAcceptInviteMember: () => ({ mutateAsync: jest.fn() }),
useMemberInvitation: () => ({ refetch: mockRefetchMemberInvitation }),
useOrganizations: () => ({ refetch: mockRefetchOrganizations }),
}))

jest.mock('@qovery/shared/util-hooks', () => ({
useLocalStorage: (key: string, initialValue: unknown) => {
const { useState } = jest.requireActual('react')

const getStoredValue = () => {
const storedValue = globalThis.localStorage.getItem(key)
return storedValue ? JSON.parse(storedValue) : initialValue
}

const [value, setValue] = useState(getStoredValue)

const setStoredValue = (nextValue: unknown) => {
const resolvedValue = typeof nextValue === 'function' ? nextValue(value) : nextValue

if (resolvedValue === undefined || resolvedValue === null) {
globalThis.localStorage.removeItem(key)
} else {
globalThis.localStorage.setItem(key, JSON.stringify(resolvedValue))
}

setValue(resolvedValue)
}

return [value, setStoredValue]
},
}))

describe('useInviteMember Hook', () => {
beforeEach(() => {
localStorage.clear()
mockUseNavigate.mockClear()
mockUseLocation.mockReturnValue({ pathname: '/', search: '' })
mockUseMatchRoute.mockImplementation(({ to, fuzzy }: { to: string; fuzzy?: boolean }) =>
fuzzy ? mockUseLocation().pathname.startsWith(to) : mockUseLocation().pathname === to
)
mockRefetchMemberInvitation.mockClear()
mockRefetchOrganizations.mockClear()
window.history.pushState({}, 'Test page', '/')
})

it('should store the tokens from the query inside localstorage and remove redirection from localStorage', async () => {
it('should initialize invitation from the query string and remove redirection from localStorage', async () => {
localStorage.setItem('redirectLoginUri', '/organization/123')
mockUseLocation.mockReturnValue({
search: '?inviteToken=123&organization=456',
pathname: 'login',
})
const SearchWrapper = ({ children }: PropsWithChildren) => (
<Wrapper route="/accept-invitation?inviteToken=123&organization=456">{children}</Wrapper>
)

const { result } = renderHook(() => useInviteMember(), { wrapper: Wrapper })
const { result } = renderHook(() => useInviteMember(), { wrapper: SearchWrapper })

const { onSearchUpdate } = result.current
await act(() => {
onSearchUpdate()
let hasInvitation: boolean | undefined
await act(async () => {
hasInvitation = result.current.onSearchUpdate()
})

expect(localStorage.getItem('inviteToken')).toBe('123')
expect(localStorage.getItem('inviteOrganizationId')).toBe('456')
expect(hasInvitation).toBe(true)
expect(JSON.parse(localStorage.getItem('inviteToken') ?? 'null')).toBe('123')
expect(JSON.parse(localStorage.getItem('inviteOrganizationId') ?? 'null')).toBe('456')
expect(localStorage.getItem('redirectLoginUri')).toBeNull()
expect(result.current.displayInvitation).toBe(true)
})

it('should redirect to the acceptation page if token found in localStorage', async () => {
localStorage.setItem('inviteToken', '123')
localStorage.setItem('inviteOrganizationId', '456')
localStorage.setItem('inviteToken', JSON.stringify('123'))
localStorage.setItem('inviteOrganizationId', JSON.stringify('456'))
mockUseLocation.mockReturnValue({
search: '',
pathname: '/organization',
})

const { result } = renderHook(() => useInviteMember(), { wrapper: Wrapper })

const { checkTokenInStorage } = result.current
await act(() => {
checkTokenInStorage()
let hasInvitation: boolean | undefined
await act(async () => {
hasInvitation = result.current.checkTokenInStorage()
})

expect(hasInvitation).toBe(true)
const { redirectToAcceptPageGuard } = result.current
await act(() => {
redirectToAcceptPageGuard()
Expand All @@ -61,9 +107,11 @@ describe('useInviteMember Hook', () => {

it('should not redirect if we are already on login', async () => {
mockUseLocation.mockReturnValue({
search: '?inviteToken=123&organization=456',
pathname: LOGIN_URL,
pathname: '/login',
search: '',
})
localStorage.setItem('inviteToken', JSON.stringify('123'))
localStorage.setItem('inviteOrganizationId', JSON.stringify('456'))

const { result } = renderHook(() => useInviteMember(), { wrapper: Wrapper })

Expand All @@ -82,9 +130,11 @@ describe('useInviteMember Hook', () => {

it('should not redirect if we are already on accept page', async () => {
mockUseLocation.mockReturnValue({
search: '?inviteToken=123&organization=456',
pathname: ACCEPT_INVITATION_URL,
pathname: '/accept-invitation',
search: '',
})
localStorage.setItem('inviteToken', JSON.stringify('123'))
localStorage.setItem('inviteOrganizationId', JSON.stringify('456'))

const { result } = renderHook(() => useInviteMember(), { wrapper: Wrapper })

Expand All @@ -102,12 +152,26 @@ describe('useInviteMember Hook', () => {
expect(mockUseNavigate).not.toHaveBeenCalled()
})

it('should set displayInvitation to false when no invite is found in query or storage', async () => {
const { result } = renderHook(() => useInviteMember(), { wrapper: Wrapper })

let hasInvitation: boolean | undefined
await act(async () => {
hasInvitation = result.current.initializeInvitation()
})

expect(hasInvitation).toBe(false)
expect(result.current.displayInvitation).toBe(false)
expect(localStorage.getItem('inviteToken')).toBeNull()
expect(localStorage.getItem('inviteOrganizationId')).toBeNull()
})

it('should remove the inviteToken from localStorage', async () => {
localStorage.setItem('inviteToken', '123')
localStorage.setItem('inviteOrganizationId', '456')
localStorage.setItem('inviteToken', JSON.stringify('123'))
localStorage.setItem('inviteOrganizationId', JSON.stringify('456'))
mockUseLocation.mockReturnValue({
search: '?inviteToken=123&organization=456',
pathname: ACCEPT_INVITATION_URL,
pathname: '/accept-invitation',
})
const { cleanInvitation } = renderHook(() => useInviteMember(), { wrapper: Wrapper }).result.current

Expand Down
Loading
Loading