diff --git a/static/app/views/settings/project/projectOwnership/addCodeOwnerModal.spec.tsx b/static/app/views/settings/project/projectOwnership/addCodeOwnerModal.spec.tsx index 55a52c0e851ae1..fcce58c354e333 100644 --- a/static/app/views/settings/project/projectOwnership/addCodeOwnerModal.spec.tsx +++ b/static/app/views/settings/project/projectOwnership/addCodeOwnerModal.spec.tsx @@ -47,7 +47,7 @@ describe('AddCodeOwnerModal', function () { }); }); - it('renders', function () { + it('renders', async function () { render( ); - - expect(screen.getByRole('button', {name: 'Add File'})).toBeDisabled(); + await waitFor(() => + expect(screen.getByRole('button', {name: 'Add File'})).toBeDisabled() + ); }); it('renders codeowner file', async function () { @@ -82,11 +83,12 @@ describe('AddCodeOwnerModal', function () { /> ); - await selectEvent.select( - screen.getByText('--'), - `Repo Name: ${codeMapping.repoName}, Stack Trace Root: ${codeMapping.stackRoot}, Source Code Root: ${codeMapping.sourceRoot}` + await waitFor(() => + selectEvent.select( + screen.getByText('--'), + `Repo Name: ${codeMapping.repoName}, Stack Trace Root: ${codeMapping.stackRoot}, Source Code Root: ${codeMapping.sourceRoot}` + ) ); - expect(screen.getByTestId('icon-check-mark')).toBeInTheDocument(); expect(screen.getByRole('button', {name: 'Preview File'})).toHaveAttribute( @@ -99,7 +101,7 @@ describe('AddCodeOwnerModal', function () { MockApiClient.addMockResponse({ url: `/organizations/${org.slug}/code-mappings/${codeMapping.id}/codeowners/`, method: 'GET', - statusCode: 404, + statusCode: 200, }); render( @@ -114,9 +116,11 @@ describe('AddCodeOwnerModal', function () { /> ); - await selectEvent.select( - screen.getByText('--'), - `Repo Name: ${codeMapping.repoName}, Stack Trace Root: ${codeMapping.stackRoot}, Source Code Root: ${codeMapping.sourceRoot}` + await waitFor(() => + selectEvent.select( + screen.getByText('--'), + `Repo Name: ${codeMapping.repoName}, Stack Trace Root: ${codeMapping.stackRoot}, Source Code Root: ${codeMapping.sourceRoot}` + ) ); expect(screen.getByText('No codeowner file found.')).toBeInTheDocument(); @@ -148,10 +152,11 @@ describe('AddCodeOwnerModal', function () { project={project} /> ); - - await selectEvent.select( - screen.getByText('--'), - `Repo Name: ${codeMapping.repoName}, Stack Trace Root: ${codeMapping.stackRoot}, Source Code Root: ${codeMapping.sourceRoot}` + await waitFor(() => + selectEvent.select( + screen.getByText('--'), + `Repo Name: ${codeMapping.repoName}, Stack Trace Root: ${codeMapping.stackRoot}, Source Code Root: ${codeMapping.sourceRoot}` + ) ); await userEvent.click(screen.getByRole('button', {name: 'Add File'})); diff --git a/static/app/views/settings/project/projectOwnership/addCodeOwnerModal.tsx b/static/app/views/settings/project/projectOwnership/addCodeOwnerModal.tsx index 54ff8bfc5df361..a816644f3d9865 100644 --- a/static/app/views/settings/project/projectOwnership/addCodeOwnerModal.tsx +++ b/static/app/views/settings/project/projectOwnership/addCodeOwnerModal.tsx @@ -1,14 +1,14 @@ -import {Fragment} from 'react'; +import {type Dispatch, Fragment, type SetStateAction, useCallback, useState} from 'react'; import styled from '@emotion/styled'; import {addErrorMessage} from 'sentry/actionCreators/indicator'; import type {ModalRenderProps} from 'sentry/actionCreators/modal'; import {Alert} from 'sentry/components/alert'; import {Button, LinkButton} from 'sentry/components/button'; -import DeprecatedAsyncComponent from 'sentry/components/deprecatedAsyncComponent'; import SelectField from 'sentry/components/forms/fields/selectField'; import Form from 'sentry/components/forms/form'; import Link from 'sentry/components/links/link'; +import LoadingError from 'sentry/components/loadingError'; import LoadingIndicator from 'sentry/components/loadingIndicator'; import Panel from 'sentry/components/panels/panel'; import PanelBody from 'sentry/components/panels/panelBody'; @@ -24,215 +24,238 @@ import type { import type {Organization} from 'sentry/types/organization'; import type {Project} from 'sentry/types/project'; import {getIntegrationIcon} from 'sentry/utils/integrationUtil'; +import { + fetchMutation, + useApiQuery, + useMutation, + type UseMutationResult, +} from 'sentry/utils/queryClient'; +import type RequestError from 'sentry/utils/requestError/requestError'; +import useApi from 'sentry/utils/useApi'; type Props = { organization: Organization; project: Project; onSave?: (data: CodeOwner) => void; -} & ModalRenderProps & - DeprecatedAsyncComponent['props']; +} & ModalRenderProps; -type State = { - codeMappingId: string | null; - codeMappings: RepositoryProjectPathConfig[]; - codeownersFile: CodeownersFile | null; - error: boolean; - errorJSON: {raw?: string} | null; - integrations: Integration[]; - isLoading: boolean; -} & DeprecatedAsyncComponent['state']; +type TCodeownersPayload = {codeMappingId: string | null; raw: string}; +type TCodeownersData = CodeOwner; +type TCodeownersError = RequestError; +type TCodeownersVariables = [TCodeownersPayload]; +type TCodeownersContext = unknown; -export default class AddCodeOwnerModal extends DeprecatedAsyncComponent { - getDefaultState() { - return { - ...super.getDefaultState(), - codeownersFile: null, - codeMappingId: null, - isLoading: false, - error: false, - errorJSON: null, - }; - } +export default function AddCodeOwnerModal({ + organization, + Header, + Body, + Footer, + project, + onSave, + closeModal, +}: Props) { + const { + data: codeMappings, + isPending: isCodeMappingsPending, + isError: isCodeMappingsError, + } = useApiQuery( + [ + `/organizations/${organization.slug}/code-mappings/`, + {query: {project: project.id}}, + ], + {staleTime: Infinity} + ); - getEndpoints(): ReturnType { - const {organization, project} = this.props; - const endpoints: ReturnType = [ - [ - 'codeMappings', - `/organizations/${organization.slug}/code-mappings/`, - {query: {project: project.id}}, - ], - [ - 'integrations', - `/organizations/${organization.slug}/integrations/`, - {query: {features: ['codeowners']}}, - ], - ]; - return endpoints; - } + const { + data: integrations, + isPending: isIntegrationsPending, + isError: isIntegrationsError, + } = useApiQuery( + [ + `/organizations/${organization.slug}/integrations/`, + {query: {features: ['codeowners']}}, + ], + {staleTime: Infinity} + ); - fetchFile = async (codeMappingId: string) => { - const {organization} = this.props; - this.setState({ - codeMappingId, - codeownersFile: null, - error: false, - errorJSON: null, - isLoading: true, - }); - try { - const data: CodeownersFile = await this.api.requestPromise( - `/organizations/${organization.slug}/code-mappings/${codeMappingId}/codeowners/`, - { - method: 'GET', - } - ); - this.setState({codeownersFile: data, isLoading: false}); - } catch (_err) { - this.setState({isLoading: false}); - } - }; + const [codeMappingId, setCodeMappingId] = useState(null); + + const {data: codeownersFile} = useApiQuery( + [`/organizations/${organization.slug}/code-mappings/${codeMappingId}/codeowners/`], + {staleTime: Infinity, enabled: Boolean(codeMappingId)} + ); - addFile = async () => { - const {organization, project, onSave, closeModal} = this.props; - const {codeownersFile, codeMappingId, codeMappings} = this.state; + const api = useApi({ + persistInFlight: false, + }); + const mutation = useMutation< + TCodeownersData, + TCodeownersError, + TCodeownersVariables, + TCodeownersContext + >({ + mutationFn: ([payload]: TCodeownersVariables) => { + return fetchMutation(api)([ + 'POST', + `/projects/${organization.slug}/${project.slug}/codeowners/`, + {}, + payload, + ]); + }, + onSuccess: d => { + const codeMapping = codeMappings?.find( + mapping => mapping.id === codeMappingId?.toString() + ); + onSave?.({...d, codeMapping}); + closeModal(); + }, + onError: err => { + if (err.responseJSON && !('raw' in err.responseJSON)) { + addErrorMessage( + Object.values(err.responseJSON ?? {}) + .flat() + .join(' ') + ); + } + }, + gcTime: 0, + }); + const addFile = useCallback(() => { if (codeownersFile) { - const postData: { - codeMappingId: string | null; - raw: string; - } = { - codeMappingId, - raw: codeownersFile.raw, - }; + mutation.mutate([{codeMappingId, raw: codeownersFile.raw}]); + } + }, [codeMappingId, codeownersFile, mutation]); - try { - const data = await this.api.requestPromise( - `/projects/${organization.slug}/${project.slug}/codeowners/`, - { - method: 'POST', - data: postData, - } - ); + if (isCodeMappingsPending || isIntegrationsPending) { + return ; + } + if (isCodeMappingsError || isIntegrationsError) { + return ; + } - const codeMapping = codeMappings.find( - mapping => mapping.id === codeMappingId?.toString() - ); + return ( + +
{t('Add Code Owner File')}
+ + {codeMappings.length ? ( + + ) : ( + + )} + +
+ +
+
+ ); +} - onSave?.({...data, codeMapping}); - closeModal(); - } catch (err) { - if (err.responseJSON.raw) { - this.setState({error: true, errorJSON: err.responseJSON, isLoading: false}); - } else { - addErrorMessage(Object.values(err.responseJSON).flat().join(' ')); - } - } - } - }; +function ApplyCodeMappings({ + codeMappingId, + codeMappings, + codeownersFile, + mutation, + organization, + setCodeMappingId, +}: { + codeMappingId: string | null; + codeMappings: RepositoryProjectPathConfig[]; + codeownersFile: CodeownersFile | undefined; + mutation: UseMutationResult; + organization: Organization; + setCodeMappingId: Dispatch>; +}) { + const baseUrl = `/settings/${organization.slug}/integrations/`; + return ( +
+ ({ + value: cm.id, + label: `Repo Name: ${cm.repoName}, Stack Trace Root: ${cm.stackRoot}, Source Code Root: ${cm.sourceRoot}`, + }))} + onChange={setCodeMappingId} + required + inline={false} + flexibleControlStateSize + stacked + /> - renderBody() { - const {organization, Header, Body, Footer} = this.props; - const { - codeownersFile, - error, - errorJSON, - isLoading, - codeMappings, - integrations, - codeMappingId, - } = this.state; - const baseUrl = `/settings/${organization.slug}/integrations/`; + + {codeownersFile ? ( + + ) : ( + + )} + {mutation.isError && mutation.error.responseJSON?.raw ? ( + + ) : null} + + + ); +} +function LinkCodeOwners({ + integrations, + organization, +}: { + integrations: Integration[]; + organization: Organization; +}) { + const baseUrl = `/settings/${organization.slug}/integrations/`; + if (integrations.length) { return ( -
{t('Add Code Owner File')}
- - {!codeMappings.length ? ( - !integrations.length ? ( - -
- {t('Install a GitHub or GitLab integration to use this feature.')} -
- - - Setup Integration - - -
- ) : ( - -
- {t( - "Configure code mapping to add your CODEOWNERS file. Select the integration you'd like to use for mapping:" - )} -
- - {integrations.map(integration => ( - - {getIntegrationIcon(integration.provider.key)} - {integration.name} - - ))} - -
- ) - ) : null} - {codeMappings.length > 0 && ( -
- ({ - value: cm.id, - label: `Repo Name: ${cm.repoName}, Stack Trace Root: ${cm.stackRoot}, Source Code Root: ${cm.sourceRoot}`, - }))} - onChange={this.fetchFile} - required - inline={false} - flexibleControlStateSize - stacked - /> - - - {codeownersFile ? ( - - ) : ( - - )} - {error && errorJSON ? ( - - ) : null} - - +
+ {t( + "Configure code mapping to add your CODEOWNERS file. Select the integration you'd like to use for mapping:" )} - -
- -
+
+ + {integrations.map(integration => ( + + {getIntegrationIcon(integration.provider.key)} + {integration.name} + + ))} +
); } + return ( + +
{t('Install a GitHub or GitLab integration to use this feature.')}
+ + + Setup Integration + + +
+ ); } function SourceFile({codeownersFile}: {codeownersFile: CodeownersFile}) { @@ -249,32 +272,12 @@ function SourceFile({codeownersFile}: {codeownersFile: CodeownersFile}) { ); } -function NoSourceFile({ - codeMappingId, - isLoading, -}: { - codeMappingId: string | null; - isLoading: boolean; -}) { - if (isLoading) { - return ( - - - - ); - } - if (!codeMappingId) { - return null; - } +function NoSourceFile() { return ( - {codeMappingId ? ( - - - {t('No codeowner file found.')} - - ) : null} + + {t('No codeowner file found.')} );