diff --git a/client/web/BUILD.bazel b/client/web/BUILD.bazel index e3ddd8962ab4..77149f9abf70 100644 --- a/client/web/BUILD.bazel +++ b/client/web/BUILD.bazel @@ -241,7 +241,7 @@ ts_project( "src/cody/management/UseCodyInEditorSection.tsx", "src/cody/management/api/client.ts", "src/cody/management/api/components/CodyProApiClient.ts", - "src/cody/management/api/react-query/QueryClientProvider.tsx", + "src/cody/management/api/react-query/CodyProApiProvider.tsx", "src/cody/management/api/react-query/callCodyProApi.ts", "src/cody/management/api/react-query/invites.ts", "src/cody/management/api/react-query/queryKeys.ts", @@ -281,6 +281,7 @@ ts_project( "src/cody/upsell/vs-code.tsx", "src/cody/useCodyChat.tsx", "src/cody/useCodyIgnore.tsx", + "src/cody/useCodyProNavLinks.ts", "src/cody/util.ts", "src/cody/widgets/CodyRecipesWidget.tsx", "src/cody/widgets/components/Recipe.tsx", @@ -1887,6 +1888,7 @@ ts_project( "src/codeintel/ReferencesPanel.test.tsx", "src/cody/team/TeamMemberList.test.ts", "src/cody/useCodyIgnore.test.ts", + "src/cody/useCodyProNavLinks.test.tsx", "src/components/ErrorBoundary.test.tsx", "src/components/FilteredConnection/FilteredConnection.test.tsx", "src/components/FilteredConnection/hooks/usePageSwitcherPagination.test.tsx", diff --git a/client/web/src/LegacySourcegraphWebApp.tsx b/client/web/src/LegacySourcegraphWebApp.tsx index 030638f9bea3..2587d1e54eb9 100644 --- a/client/web/src/LegacySourcegraphWebApp.tsx +++ b/client/web/src/LegacySourcegraphWebApp.tsx @@ -41,6 +41,7 @@ import { WildcardThemeContext, type WildcardTheme } from '@sourcegraph/wildcard' import { authenticatedUser as authenticatedUserSubject, authenticatedUserValue, type AuthenticatedUser } from './auth' import { getWebGraphQLClient } from './backend/graphql' import { isBatchChangesExecutionEnabled } from './batches' +import { CodyProApiProvider } from './cody/management/api/react-query/CodyProApiProvider' import { ComponentsComposer } from './components/ComponentsComposer' import { ErrorBoundary } from './components/ErrorBoundary' import { FeatureFlagsLocalOverrideAgent } from './featureFlags/FeatureFlagsProvider' @@ -258,6 +259,7 @@ export class LegacySourcegraphWebApp extends React.Component, , , + , /* eslint-enable react/no-children-prop, react/jsx-key */ ]} > diff --git a/client/web/src/SourcegraphWebApp.tsx b/client/web/src/SourcegraphWebApp.tsx index 11795aa0794f..1d9fc2bc5d6a 100644 --- a/client/web/src/SourcegraphWebApp.tsx +++ b/client/web/src/SourcegraphWebApp.tsx @@ -29,6 +29,7 @@ import type { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry' import { WildcardThemeContext, type WildcardTheme } from '@sourcegraph/wildcard' import { authenticatedUser as authenticatedUserSubject, type AuthenticatedUser, authenticatedUserValue } from './auth' +import { CodyProApiProvider } from './cody/management/api/react-query/CodyProApiProvider' import { ComponentsComposer } from './components/ComponentsComposer' import { ErrorBoundary, RouteError } from './components/ErrorBoundary' import { FeatureFlagsLocalOverrideAgent } from './featureFlags/FeatureFlagsProvider' @@ -283,6 +284,7 @@ export const SourcegraphWebApp: FC = props => { ...props, }} />, + , /* eslint-enable react/no-children-prop, react/jsx-key */ ]} > diff --git a/client/web/src/cody/codyProRoutes.tsx b/client/web/src/cody/codyProRoutes.tsx index 5d7f6005e5ac..bda86e234b21 100644 --- a/client/web/src/cody/codyProRoutes.tsx +++ b/client/web/src/cody/codyProRoutes.tsx @@ -4,7 +4,6 @@ import { lazyComponent } from '@sourcegraph/shared/src/util/lazyComponent' import { LegacyRoute, type LegacyLayoutRouteContext } from '../LegacyRouteContext' -import { QueryClientProvider } from './management/api/react-query/QueryClientProvider' import { isEmbeddedCodyProUIEnabled } from './util' export enum CodyProRoutes { @@ -78,9 +77,5 @@ interface CodyProPageProps extends Pick = props => { const Component = routeComponents[props.path] - return ( - - - - ) + return } diff --git a/client/web/src/cody/management/api/react-query/QueryClientProvider.tsx b/client/web/src/cody/management/api/react-query/CodyProApiProvider.tsx similarity index 56% rename from client/web/src/cody/management/api/react-query/QueryClientProvider.tsx rename to client/web/src/cody/management/api/react-query/CodyProApiProvider.tsx index 98f0d38386a1..18e4192ce838 100644 --- a/client/web/src/cody/management/api/react-query/QueryClientProvider.tsx +++ b/client/web/src/cody/management/api/react-query/CodyProApiProvider.tsx @@ -1,4 +1,4 @@ -import { QueryClient, QueryClientProvider as ReactQueryClientProvider } from '@tanstack/react-query' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' // Tweak the default queries and mutations behavior. // See defaults here: https://tanstack.com/query/latest/docs/framework/react/guides/important-defaults @@ -15,6 +15,10 @@ const queryClient = new QueryClient({ }, }) -export const QueryClientProvider: React.FC> = ({ children }) => ( - {children} +/** + * CodyProApiProvider wraps its children with the react-query QueryClientProvider. + * It is used to access the Cody Pro API and is only utilized on dotcom. + */ +export const CodyProApiProvider: React.FC> = ({ children }) => ( + {children} ) diff --git a/client/web/src/cody/management/subscription/manage/CodySubscriptionManagePage.tsx b/client/web/src/cody/management/subscription/manage/CodySubscriptionManagePage.tsx index 357bf0db8ef5..9310665f0f74 100644 --- a/client/web/src/cody/management/subscription/manage/CodySubscriptionManagePage.tsx +++ b/client/web/src/cody/management/subscription/manage/CodySubscriptionManagePage.tsx @@ -20,7 +20,7 @@ import type { LegacyLayoutRouteContext } from '../../../../LegacyRouteContext' import { CodyProRoutes } from '../../../codyProRoutes' import { PageHeaderIcon } from '../../../components/PageHeaderIcon' import { USER_CODY_PLAN } from '../../../subscription/queries' -import { useCurrentSubscription } from '../../api/react-query/subscriptions' +import { useCurrentSubscription, useSubscriptionSummary } from '../../api/react-query/subscriptions' import { InvoiceHistory } from './InvoiceHistory' import { PaymentDetails } from './PaymentDetails' @@ -38,6 +38,7 @@ const AuthenticatedCodySubscriptionManagePage: React.FC = ({ telemetryRec error: useCodyPlanError, data: userCodyPlanData, } = useQuery(USER_CODY_PLAN, {}) + const subscriptionSummaryQuery = useSubscriptionSummary() useEffect( function recordViewEvent() { @@ -46,7 +47,7 @@ const AuthenticatedCodySubscriptionManagePage: React.FC = ({ telemetryRec [telemetryRecorder] ) - if (userCodyPlanLoading) { + if (userCodyPlanLoading || subscriptionSummaryQuery.isLoading) { return } @@ -55,18 +56,32 @@ const AuthenticatedCodySubscriptionManagePage: React.FC = ({ telemetryRec return null } + if (subscriptionSummaryQuery.isError) { + logger.error('Failed to fetch Cody subscription summary', subscriptionSummaryQuery.error) + return null + } + const subscriptionData = userCodyPlanData?.currentUser?.codySubscription if (!subscriptionData) { logger.error('Cody subscription data is not available.') return null } + if (!subscriptionSummaryQuery.data) { + logger.error('Cody subscription summary is not available.') + return null + } + // This page only applies to users who have a Cody Pro subscription to manage. // Otherwise, direct them to the ./new page to sign up. if (subscriptionData.plan !== CodySubscriptionPlan.PRO) { return } + if (subscriptionSummaryQuery.data.userRole !== 'admin') { + return + } + return ( diff --git a/client/web/src/cody/useCodyProNavLinks.test.tsx b/client/web/src/cody/useCodyProNavLinks.test.tsx new file mode 100644 index 000000000000..f7d27a589b61 --- /dev/null +++ b/client/web/src/cody/useCodyProNavLinks.test.tsx @@ -0,0 +1,85 @@ +import { renderHook } from '@testing-library/react' +import { vi, describe, afterEach, test, expect, beforeEach } from 'vitest' + +import { CodyProRoutes } from './codyProRoutes' +import * as subscriptionQueries from './management/api/react-query/subscriptions' +import type { SubscriptionSummary } from './management/api/teamSubscriptions' +import { useCodyProNavLinks } from './useCodyProNavLinks' + +describe('useCodyProNavLinks', () => { + const useSubscriptionSummaryMock = vi.spyOn(subscriptionQueries, 'useSubscriptionSummary') + + afterEach(() => { + useSubscriptionSummaryMock.mockReset() + }) + + const mockSubscriptionSummary = (summary?: SubscriptionSummary): void => { + useSubscriptionSummaryMock.mockReturnValue({ data: summary } as ReturnType< + typeof subscriptionQueries.useSubscriptionSummary + >) + } + + test('returns empty array if subscription summary is undefined', () => { + mockSubscriptionSummary() + const { result } = renderHook(() => useCodyProNavLinks()) + expect(result.current).toHaveLength(0) + }) + + test('returns empty array user is not admin', () => { + const summary: SubscriptionSummary = { + teamId: '018ff1b3-118c-7789-82e4-ab9106eed204', + userRole: 'member', + teamCurrentMembers: 2, + teamMaxMembers: 6, + subscriptionStatus: 'active', + cancelAtPeriodEnd: false, + } + mockSubscriptionSummary(summary) + const { result } = renderHook(() => useCodyProNavLinks()) + expect(result.current).toHaveLength(0) + }) + + describe('user is admin', () => { + const summary: SubscriptionSummary = { + teamId: '018ff1b3-118c-7789-82e4-ab9106eed204', + userRole: 'admin', + teamCurrentMembers: 2, + teamMaxMembers: 6, + subscriptionStatus: 'active', + cancelAtPeriodEnd: false, + } + + const setUseEmbeddedUI = (useEmbeddedUI: boolean) => { + vi.stubGlobal('context', { + frontendCodyProConfig: { + stripePublishableKey: 'pk_test_123', + sscBaseUrl: '', + useEmbeddedUI, + }, + }) + } + + beforeEach(() => { + vi.stubGlobal('context', {}) + mockSubscriptionSummary(summary) + }) + + test.skip('returns links to subscription and team management pages if embedded UI is enabled', () => { + setUseEmbeddedUI(true) + const { result } = renderHook(() => useCodyProNavLinks()) + expect(result.current).toHaveLength(2) + expect(result.current[0].label).toBe('Manage subscription') + expect(result.current[0].to).toBe(CodyProRoutes.SubscriptionManage) + expect(result.current[1].label).toBe('Manage team') + expect(result.current[1].to).toBe(CodyProRoutes.ManageTeam) + }) + + test('returns link to subscription management page if embedded UI is disabled', () => { + setUseEmbeddedUI(false) + const { result } = renderHook(() => useCodyProNavLinks()) + expect(result.current).toHaveLength(1) + expect(result.current[0].label).toBe('Manage subscription') + expect(result.current[0].to).toBe('https://accounts.sourcegraph.com/cody/subscription') + }) + }) +}) diff --git a/client/web/src/cody/useCodyProNavLinks.ts b/client/web/src/cody/useCodyProNavLinks.ts new file mode 100644 index 000000000000..fe79e853a3e2 --- /dev/null +++ b/client/web/src/cody/useCodyProNavLinks.ts @@ -0,0 +1,23 @@ +import { useMemo } from 'react' + +import { CodyProRoutes } from './codyProRoutes' +import { useSubscriptionSummary } from './management/api/react-query/subscriptions' +import { getManageSubscriptionPageURL, isEmbeddedCodyProUIEnabled } from './util' + +export const useCodyProNavLinks = (): { to: string; label: string }[] => { + const { data } = useSubscriptionSummary() + + return useMemo(() => { + if (!data || data.userRole !== 'admin') { + return [] + } + + const items = [{ to: getManageSubscriptionPageURL(), label: 'Manage subscription' }] + + if (isEmbeddedCodyProUIEnabled()) { + items.push({ to: CodyProRoutes.ManageTeam, label: 'Manage team' }) + } + + return items + }, [data]) +} diff --git a/client/web/src/nav/UserNavItem.test.tsx b/client/web/src/nav/UserNavItem.test.tsx index 79dbe461a957..415dc135c672 100644 --- a/client/web/src/nav/UserNavItem.test.tsx +++ b/client/web/src/nav/UserNavItem.test.tsx @@ -2,13 +2,15 @@ import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { MemoryRouter } from 'react-router-dom' import sinon from 'sinon' -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'vitest' +import { afterAll, beforeAll, afterEach, describe, expect, test, vi, beforeEach } from 'vitest' import { NOOP_TELEMETRY_SERVICE } from '@sourcegraph/shared/src/telemetry/telemetryService' import { MockedTestProvider } from '@sourcegraph/shared/src/testing/apollo' import { AnchorLink, RouterLink, setLinkComponent } from '@sourcegraph/wildcard' import { renderWithBrandedContext } from '@sourcegraph/wildcard/src/testing' +import * as codyProHooks from '../cody/useCodyProNavLinks' + import { UserNavItem, type UserNavItemProps } from './UserNavItem' describe('UserNavItem', () => { @@ -30,6 +32,14 @@ describe('UserNavItem', () => { setLinkComponent(AnchorLink) }) + const useCodyProNavLinksMock = vi.spyOn(codyProHooks, 'useCodyProNavLinks') + beforeEach(() => { + useCodyProNavLinksMock.mockReturnValue([]) + }) + afterEach(() => { + useCodyProNavLinksMock.mockReset() + }) + const USER: UserNavItemProps['authenticatedUser'] = { username: 'alice', displayName: 'alice doe', @@ -100,4 +110,54 @@ describe('UserNavItem', () => { expect(result.locationRef.entries.length).toBe(1) expect(result.locationRef.entries.find(({ pathname }) => pathname.includes('sign-out'))).toBe(undefined) }) + + describe('Cody Pro section', () => { + const setup = (isSourcegraphDotCom: boolean) => { + renderWithBrandedContext( + + undefined} + authenticatedUser={USER} + isSourcegraphDotCom={isSourcegraphDotCom} + showFeedbackModal={() => undefined} + telemetryService={NOOP_TELEMETRY_SERVICE} + /> + + ) + userEvent.click(screen.getByRole('button')) + } + + describe('dotcom', () => { + test('renders provided links', () => { + const links = [ + { to: '/foo', label: 'Foo' }, + { to: '/bar', label: 'Bar' }, + ] + useCodyProNavLinksMock.mockReturnValue(links) + setup(true) + + for (const link of links) { + const el = screen.getByText(link.label) + expect(el).toHaveAttribute('href', link.to) + } + }) + + test('is not rendered if no links provided', () => { + useCodyProNavLinksMock.mockReturnValue([]) + setup(true) + + expect(useCodyProNavLinksMock).toHaveBeenCalled() + expect(screen.queryByText('Cody Pro')).not.toBeInTheDocument() + }) + }) + + describe('enterprise', () => { + test('is not rendered', () => { + setup(false) + + // Cody Pro section is not rendered thus useCodyProNavLinks hook is not called + expect(useCodyProNavLinksMock).not.toHaveBeenCalled() + }) + }) + }) }) diff --git a/client/web/src/nav/UserNavItem.tsx b/client/web/src/nav/UserNavItem.tsx index 88c8d30f3d57..81cf3ae6d0a2 100644 --- a/client/web/src/nav/UserNavItem.tsx +++ b/client/web/src/nav/UserNavItem.tsx @@ -27,6 +27,7 @@ import { import type { AuthenticatedUser } from '../auth' import { CodyProRoutes } from '../cody/codyProRoutes' +import { useCodyProNavLinks } from '../cody/useCodyProNavLinks' import { PageRoutes } from '../routes.constants' import { enableDevSettings, isSourcegraphDev, useDeveloperSettings } from '../stores' @@ -132,6 +133,7 @@ export const UserNavItem: FC = props => { Signed in as @{authenticatedUser.username} + {isSourcegraphDotCom && } Settings @@ -251,3 +253,24 @@ export const UserNavItem: FC = props => { ) } + +const CodyProSection: React.FC = () => { + const links = useCodyProNavLinks() + + if (!links.length) { + return null + } + + return ( + <> + + Cody PRO + + {links.map(({ to, label }) => ( + + {label} + + ))} + + ) +}