diff --git a/static/app/components/gameConsole/RequestSdkAccessButton.tsx b/static/app/components/gameConsole/RequestSdkAccessButton.tsx new file mode 100644 index 00000000000000..a69110f7dcac0b --- /dev/null +++ b/static/app/components/gameConsole/RequestSdkAccessButton.tsx @@ -0,0 +1,37 @@ +import {Button} from '@sentry/scraps/button'; + +import {openPrivateGamingSdkAccessModal} from 'sentry/actionCreators/modal'; +import type {PrivateGamingSdkAccessModalProps} from 'sentry/components/modals/privateGamingSdkAccessModal'; +import {IconLock} from 'sentry/icons'; +import {t} from 'sentry/locale'; +import {useReopenGamingSdkModal} from 'sentry/utils/useReopenGamingSdkModal'; + +export function RequestSdkAccessButton({ + gamingPlatform, + organization, + origin, + projectId, +}: Omit) { + const buttonProps: PrivateGamingSdkAccessModalProps = { + gamingPlatform, + organization, + origin, + projectId, + }; + + useReopenGamingSdkModal(buttonProps); + + return ( + + ); +} diff --git a/static/app/components/modals/privateGamingSdkAccessModal.tsx b/static/app/components/modals/privateGamingSdkAccessModal.tsx index 84e3077b6a2279..f42e218fc214ab 100644 --- a/static/app/components/modals/privateGamingSdkAccessModal.tsx +++ b/static/app/components/modals/privateGamingSdkAccessModal.tsx @@ -1,33 +1,51 @@ import {Fragment, useEffect, useState} from 'react'; -import * as Sentry from '@sentry/react'; -import {addSuccessMessage} from 'sentry/actionCreators/indicator'; +import {Alert} from '@sentry/scraps/alert'; +import {Button} from '@sentry/scraps/button'; +import {ButtonBar} from '@sentry/scraps/button/buttonBar'; +import {Prose} from '@sentry/scraps/text/prose'; + +import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; import {type ModalRenderProps} from 'sentry/actionCreators/modal'; -import {Alert} from 'sentry/components/core/alert'; -import {Button} from 'sentry/components/core/button'; -import {ButtonBar} from 'sentry/components/core/button/buttonBar'; +import {ExternalLink} from 'sentry/components/core/link'; import SelectField from 'sentry/components/forms/fields/selectField'; -import TextField from 'sentry/components/forms/fields/textField'; +import LoadingError from 'sentry/components/loadingError'; +import LoadingIndicator from 'sentry/components/loadingIndicator'; +import {CONSOLE_PLATFORM_METADATA} from 'sentry/constants/consolePlatforms'; +import {IconGithub} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; +import type {UserIdentityConfig} from 'sentry/types/auth'; import type {Organization} from 'sentry/types/organization'; import {trackAnalytics} from 'sentry/utils/analytics'; -import {handleXhrErrorResponse} from 'sentry/utils/handleXhrErrorResponse'; -import {useUser} from 'sentry/utils/useUser'; +import { + fetchMutation, + useApiQuery, + useMutation, + useQueryClient, +} from 'sentry/utils/queryClient'; +import type RequestError from 'sentry/utils/requestError/requestError'; +import {useLocation} from 'sentry/utils/useLocation'; + +type GamingPlatform = 'playstation' | 'xbox' | 'nintendo-switch'; + +interface ConsoleSdkInvitePlatformError { + error: string; + platform: string; +} -const PRIVATE_GAMING_SDK_OPTIONS = [ - {value: 'playstation', label: 'PlayStation'}, - {value: 'xbox', label: 'Xbox'}, - {value: 'nintendo-switch', label: 'Nintendo Switch'}, -] as const; +interface ConsoleSdkInviteResponse { + errors: ConsoleSdkInvitePlatformError[] | null; + success: true; +} -type GamingPlatform = (typeof PRIVATE_GAMING_SDK_OPTIONS)[number]['value']; +interface ConsoleSdkInviteRequest { + platforms: GamingPlatform[]; +} export interface PrivateGamingSdkAccessModalProps { organization: Organization; - origin: 'onboarding' | 'project-creation' | 'project-settings'; + origin: 'onboarding' | 'project-creation' | 'project-settings' | 'org-settings'; projectId: string; - projectSlug: string; - sdkName: string; gamingPlatform?: GamingPlatform; onSubmit?: () => void; } @@ -38,22 +56,89 @@ export function PrivateGamingSdkAccessModal({ Footer, closeModal, organization, - projectSlug, - sdkName, gamingPlatform, projectId, onSubmit, origin, }: PrivateGamingSdkAccessModalProps & ModalRenderProps) { - const user = useUser(); - const [isSubmitting, setIsSubmitting] = useState(false); - const [githubProfile, setGithubProfile] = useState(''); - const [gamingPlatforms, setGamingPlatforms] = useState( + const [gamingPlatforms, setGamingPlatforms] = useState( gamingPlatform ? [gamingPlatform] : [] ); - const [requestError, setRequestError] = useState(undefined); + const [submittedPlatforms, setSubmittedPlatforms] = useState([]); + const location = useLocation(); + const currentPath = location.pathname + location.search; + const queryClient = useQueryClient(); - const isFormValid = !!githubProfile.trim() && gamingPlatforms.length > 0; + const { + isPending, + isError, + data: userIdentities, + refetch, + } = useApiQuery(['/users/me/user-identities/'], { + staleTime: Infinity, + }); + + const mutation = useMutation< + ConsoleSdkInviteResponse, + RequestError, + ConsoleSdkInviteRequest + >({ + mutationFn: ({platforms}: ConsoleSdkInviteRequest) => + fetchMutation({ + url: `/organizations/${organization.slug}/console-sdk-invites/`, + method: 'POST', + data: {platforms}, + }), + onSuccess: (response, {platforms}) => { + const platformsWithErrors = response.errors?.map(e => e.platform) ?? []; + const successfulPlatforms = platforms.filter( + platform => !platformsWithErrors.includes(platform) + ); + + if (platformsWithErrors.length > 0) { + addErrorMessage( + tct( + 'Invitation to console repositories for these platforms have failed: [errors]', + { + errors: platformsWithErrors.join(','), + } + ) + ); + } + + if (successfulPlatforms.length > 0) { + addSuccessMessage( + tct('Invitation to these platforms has been sent: [platforms]', { + platforms: successfulPlatforms.join(','), + }) + ); + setSubmittedPlatforms(successfulPlatforms); + } + + queryClient.invalidateQueries({ + queryKey: [`/organizations/${organization.slug}/console-sdk-invites/`], + }); + }, + onError: errorResponse => { + const errorMessage = tct('[error] - [detail]', { + error: + typeof errorResponse.responseJSON?.error === 'string' + ? errorResponse.responseJSON.error + : t('Error occurred'), + detail: + typeof errorResponse.responseJSON?.detail === 'string' + ? errorResponse.responseJSON.detail + : t('Unknown Error occurred'), + }); + addErrorMessage(errorMessage); + }, + }); + + const hasGithubIdentity = userIdentities?.some( + userIdentity => userIdentity.provider.key === 'github' + ); + const isFormValid = hasGithubIdentity && gamingPlatforms.length > 0; + const showSuccessView = mutation.isSuccess && submittedPlatforms.length > 0; useEffect(() => { trackAnalytics('gaming.private_sdk_access_modal_opened', { @@ -64,150 +149,139 @@ export function PrivateGamingSdkAccessModal({ }); }, [gamingPlatform, organization, projectId, origin]); - async function handleSubmit() { + function handleSubmit() { if (!isFormValid) { return; } - setIsSubmitting(true); - setRequestError(undefined); - trackAnalytics('gaming.private_sdk_access_modal_submitted', { - platforms: gamingPlatforms, - project_id: projectId, platform: gamingPlatform, + project_id: projectId, organization, origin, + platforms: gamingPlatforms, }); onSubmit?.(); - - const messageBody = [ - `This is a request for SDK access for consoles. The user's details are:`, - `User: ${user.name}`, - `Email: ${user.email}`, - gamingPlatforms.length === 1 - ? `Platform: ${gamingPlatforms[0]}` - : `Platforms: ${gamingPlatforms - .map( - (platform: string) => - PRIVATE_GAMING_SDK_OPTIONS.find(option => option.value === platform) - ?.label || platform - ) - .join(', ')}`, - `Org Slug: ${organization.slug}`, - `Project: ${projectSlug}`, - `GitHub Profile: ${githubProfile}`, - ].join('\n'); - - try { - await Sentry.sendFeedback( - { - message: messageBody, - name: user.name, - email: user.email, - tags: { - feature: 'console-sdk-access', - }, - }, - { - captureContext: { - user: { - id: user.id, - email: user.email, - username: user.username, - name: user.name, - }, - }, - } - ); - - addSuccessMessage( - tct('Your [sdkName] SDK access request has been submitted.', { - sdkName, - }) - ); - closeModal(); - } catch (error: any) { - handleXhrErrorResponse(t('Unable to submit SDK access request'), error); - - setRequestError( - // Ideally, we’d get an error code to use with our translation functions for showing the right message, but the API currently only returns a plain string. - error instanceof Error - ? error.message - : typeof error === 'string' - ? error - : t( - 'Unable to submit the request. This could be because of network issues, or because you are using an ad-blocker.' - ) - ); - } finally { - setIsSubmitting(false); - } + mutation.mutate({platforms: gamingPlatforms}); } return (
-

- {tct('Request [sdkName] SDK Access', { - sdkName, - })} -

+

{t('Request console SDK Access')}

-

- {gamingPlatform - ? tct( - 'Request access to our [sdkName] SDK. Please provide your GitHub profile.', - { - sdkName, - } - ) - : tct( - 'Request access to our [sdkName] SDK. Please provide your GitHub profile and the gaming platforms you work with.', - { - sdkName, - } + {showSuccessView ? ( + +

+ {t( + 'You have been invited to our private game console SDK GitHub repositories!' + )} +

+

+ {t( + 'You should get your invites in your GitHub notifications any minute. If you have notifications disabled, click the link below to access the private repos:' + )} +

+
    + {submittedPlatforms.map(platform => { + const metadata = CONSOLE_PLATFORM_METADATA[platform]; + return ( +
  • + + {metadata?.displayName} + +
  • + ); + })} +
+ + ) : isPending ? ( + + ) : isError ? ( + + ) : hasGithubIdentity ? ( + +

+ {t( + 'Select the gaming platforms you need access to. You will receive GitHub repository invitations for each platform.' )} -

- - {!gamingPlatform && ( - +

+ ({ + value, + label: + CONSOLE_PLATFORM_METADATA[value as GamingPlatform]?.displayName ?? + value, + }))} + value={gamingPlatforms} + onChange={setGamingPlatforms} + multiple + required + stacked + inline={false} + /> +
+ ) : ( + +

+ {t( + 'To request SDK access, you need to link your GitHub account with your Sentry account.' + )} +

+ +
+ )} + {mutation.error && ( + + {tct('[error] - [detail]', { + error: + typeof mutation.error.responseJSON?.error === 'string' + ? mutation.error.responseJSON.error + : t('Error occurred'), + detail: + typeof mutation.error.responseJSON?.detail === 'string' + ? mutation.error.responseJSON.detail + : t('Unknown Error occurred'), + })} + )} - {requestError && {requestError}}
- - + {showSuccessView ? ( + + ) : ( + + + {hasGithubIdentity && ( + + )} + + )}
diff --git a/static/app/components/onboarding/gettingStartedDoc/utils/consoleExtensions.tsx b/static/app/components/onboarding/gettingStartedDoc/utils/consoleExtensions.tsx index 13c60a3c47806b..534d47cbbfd502 100644 --- a/static/app/components/onboarding/gettingStartedDoc/utils/consoleExtensions.tsx +++ b/static/app/components/onboarding/gettingStartedDoc/utils/consoleExtensions.tsx @@ -1,9 +1,8 @@ import {useState} from 'react'; -import {openPrivateGamingSdkAccessModal} from 'sentry/actionCreators/modal'; -import {Button} from 'sentry/components/core/button'; import {ExternalLink} from 'sentry/components/core/link'; import {SegmentedControl} from 'sentry/components/core/segmentedControl'; +import {RequestSdkAccessButton} from 'sentry/components/gameConsole/RequestSdkAccessButton'; import {CONSOLE_PLATFORM_INSTRUCTIONS} from 'sentry/components/onboarding/consoleModal'; import {ContentBlocksRenderer} from 'sentry/components/onboarding/gettingStartedDoc/contentBlocks/renderer'; import type { @@ -16,9 +15,7 @@ import { ConsolePlatform, } from 'sentry/constants/consolePlatforms'; import platforms from 'sentry/data/platforms'; -import {IconLock} from 'sentry/icons/iconLock'; import {t, tct} from 'sentry/locale'; -import {RequestSdkAccessButton} from 'sentry/views/settings/project/tempest/RequestSdkAccessButton'; function getPlayStationRequestButtonAccessDescription(platform?: string) { switch (platform) { @@ -97,8 +94,9 @@ function getEnabledPlayStationContent(params: DocsParams): ContentBlock[] { type: 'custom', content: ( ), @@ -147,22 +145,12 @@ function getEnabledNintendoSwitchContent(params: DocsParams): ContentBlock[] { { type: 'custom', content: ( - + ), }, { @@ -204,22 +192,12 @@ function getEnabledXboxContent(params: DocsParams): ContentBlock[] { { type: 'custom', content: ( - + ), }, { diff --git a/static/app/constants/consolePlatforms.ts b/static/app/constants/consolePlatforms.ts index a73837e63fa7d2..a1c7d15cab9098 100644 --- a/static/app/constants/consolePlatforms.ts +++ b/static/app/constants/consolePlatforms.ts @@ -4,17 +4,20 @@ export enum ConsolePlatform { XBOX = 'xbox', } +// Repository owner for console SDK repositories. Change this for testing with your own GitHub org. +const CONSOLE_SDK_REPO_OWNER = 'getsentry'; + export const CONSOLE_PLATFORM_METADATA = { [ConsolePlatform.NINTENDO_SWITCH]: { displayName: 'Nintendo Switch', - repoURL: 'https://github.com/getsentry/sentry-switch', + repoURL: `https://github.com/${CONSOLE_SDK_REPO_OWNER}/sentry-switch`, }, [ConsolePlatform.PLAYSTATION]: { displayName: 'PlayStation', - repoURL: 'https://github.com/getsentry/sentry-playstation', + repoURL: `https://github.com/${CONSOLE_SDK_REPO_OWNER}/sentry-playstation`, }, [ConsolePlatform.XBOX]: { displayName: 'Xbox', - repoURL: 'https://github.com/getsentry/sentry-xbox', + repoURL: `https://github.com/${CONSOLE_SDK_REPO_OWNER}/sentry-xbox`, }, }; diff --git a/static/app/gettingStartedDocs/nintendo-switch/onboarding.tsx b/static/app/gettingStartedDocs/nintendo-switch/onboarding.tsx index b1c9ea99197f9b..074ffe547dffdb 100644 --- a/static/app/gettingStartedDocs/nintendo-switch/onboarding.tsx +++ b/static/app/gettingStartedDocs/nintendo-switch/onboarding.tsx @@ -1,6 +1,5 @@ -import {openPrivateGamingSdkAccessModal} from 'sentry/actionCreators/modal'; -import {Button} from 'sentry/components/core/button'; import {ExternalLink} from 'sentry/components/core/link'; +import {RequestSdkAccessButton} from 'sentry/components/gameConsole/RequestSdkAccessButton'; import List from 'sentry/components/list'; import ListItem from 'sentry/components/list/listItem'; import { @@ -41,22 +40,12 @@ export const onboarding: OnboardingConfig = { ), showIcon: true, trailingItems: ( - + ), }, { diff --git a/static/app/gettingStartedDocs/playstation/onboarding.tsx b/static/app/gettingStartedDocs/playstation/onboarding.tsx index 8735574225f55f..ce62f4c97c2988 100644 --- a/static/app/gettingStartedDocs/playstation/onboarding.tsx +++ b/static/app/gettingStartedDocs/playstation/onboarding.tsx @@ -7,6 +7,7 @@ import windowToolImg from 'sentry-images/tempest/windows-tool-devkit.png'; import {Flex} from 'sentry/components/core/layout/flex'; import {ExternalLink} from 'sentry/components/core/link'; +import {RequestSdkAccessButton} from 'sentry/components/gameConsole/RequestSdkAccessButton'; import List from 'sentry/components/list'; import ListItem from 'sentry/components/list/listItem'; import { @@ -21,7 +22,6 @@ import { AllowListIPAddresses, } from 'sentry/views/settings/project/tempest/allowListIPAddresses'; import {ConfigForm} from 'sentry/views/settings/project/tempest/configForm'; -import {RequestSdkAccessButton} from 'sentry/views/settings/project/tempest/RequestSdkAccessButton'; const isRetailMode = (params: DocsParams) => params.platformOptions?.installationMode === InstallationMode.RETAIL; @@ -269,8 +269,9 @@ export const onboarding: OnboardingConfig = { type: 'custom', content: ( ), diff --git a/static/app/gettingStartedDocs/xbox/onboarding.tsx b/static/app/gettingStartedDocs/xbox/onboarding.tsx index 000f451295f267..aa7f5d384d3a51 100644 --- a/static/app/gettingStartedDocs/xbox/onboarding.tsx +++ b/static/app/gettingStartedDocs/xbox/onboarding.tsx @@ -1,6 +1,5 @@ -import {openPrivateGamingSdkAccessModal} from 'sentry/actionCreators/modal'; -import {Button} from 'sentry/components/core/button'; import {ExternalLink} from 'sentry/components/core/link'; +import {RequestSdkAccessButton} from 'sentry/components/gameConsole/RequestSdkAccessButton'; import { StepType, type OnboardingConfig, @@ -43,22 +42,12 @@ export const onboarding: OnboardingConfig = { ), showIcon: true, trailingItems: ( - + ), }, { diff --git a/static/app/utils/analytics/gamingAnalyticsEvents.tsx b/static/app/utils/analytics/gamingAnalyticsEvents.tsx index a4616f008054a6..85ca017faa9676 100644 --- a/static/app/utils/analytics/gamingAnalyticsEvents.tsx +++ b/static/app/utils/analytics/gamingAnalyticsEvents.tsx @@ -1,5 +1,5 @@ type GamingPlatformBase = { - origin: 'onboarding' | 'project-creation' | 'project-settings'; + origin: 'onboarding' | 'project-creation' | 'project-settings' | 'org-settings'; platform?: string; }; diff --git a/static/app/utils/useReopenGamingSdkModal.tsx b/static/app/utils/useReopenGamingSdkModal.tsx new file mode 100644 index 00000000000000..32427ec9e21b8c --- /dev/null +++ b/static/app/utils/useReopenGamingSdkModal.tsx @@ -0,0 +1,24 @@ +import {useEffect, useRef} from 'react'; +import {useQueryState} from 'nuqs'; + +import {openPrivateGamingSdkAccessModal} from 'sentry/actionCreators/modal'; +import type {PrivateGamingSdkAccessModalProps} from 'sentry/components/modals/privateGamingSdkAccessModal'; +import {parseAsBooleanLiteral} from 'sentry/utils/url/parseAsBooleanLiteral'; + +export function useReopenGamingSdkModal( + modalProps: Omit & {onSubmit?: () => void} +) { + const [reopenModal, setReopenModal] = useQueryState( + 'reopenGamingSdkModal', + parseAsBooleanLiteral.withOptions({history: 'replace'}) + ); + const modalPropsRef = useRef(modalProps); + modalPropsRef.current = modalProps; + + useEffect(() => { + if (reopenModal) { + setReopenModal(null); + openPrivateGamingSdkAccessModal(modalPropsRef.current); + } + }, [reopenModal, setReopenModal]); +} diff --git a/static/app/views/settings/project/tempest/RequestSdkAccessButton.tsx b/static/app/views/settings/project/tempest/RequestSdkAccessButton.tsx deleted file mode 100644 index 4a0cb37d464b14..00000000000000 --- a/static/app/views/settings/project/tempest/RequestSdkAccessButton.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import {openPrivateGamingSdkAccessModal} from 'sentry/actionCreators/modal'; -import {Button} from 'sentry/components/core/button'; -import {IconLock} from 'sentry/icons'; -import {t} from 'sentry/locale'; -import type {Organization} from 'sentry/types/organization'; -import type {Project} from 'sentry/types/project'; -import {trackAnalytics} from 'sentry/utils/analytics'; - -interface RequestSdkAccessButtonProps { - organization: Organization; - origin: 'onboarding' | 'project-creation' | 'project-settings'; - project: Project; -} - -export function RequestSdkAccessButton({ - organization, - project, - origin, -}: RequestSdkAccessButtonProps) { - return ( - - ); -} diff --git a/static/app/views/settings/project/tempest/index.tsx b/static/app/views/settings/project/tempest/index.tsx index ca2ff24cfb0f47..77495ee311e8bd 100644 --- a/static/app/views/settings/project/tempest/index.tsx +++ b/static/app/views/settings/project/tempest/index.tsx @@ -5,6 +5,7 @@ import {Button} from 'sentry/components/core/button'; import {ButtonBar} from 'sentry/components/core/button/buttonBar'; import {TabList, Tabs} from 'sentry/components/core/tabs'; import FeedbackButton from 'sentry/components/feedbackButton/feedbackButton'; +import {RequestSdkAccessButton} from 'sentry/components/gameConsole/RequestSdkAccessButton'; import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle'; import {IconClose} from 'sentry/icons'; import {t} from 'sentry/locale'; @@ -16,7 +17,6 @@ import {useNavigate} from 'sentry/utils/useNavigate'; import useOrganization from 'sentry/utils/useOrganization'; import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader'; import {useProjectSettingsOutlet} from 'sentry/views/settings/project/projectSettingsLayout'; -import {RequestSdkAccessButton} from 'sentry/views/settings/project/tempest/RequestSdkAccessButton'; import DevKitSettings from './DevKitSettings'; import PlayStationSettings from './PlayStationSettings'; @@ -112,8 +112,9 @@ export default function TempestSettings() {