Browse Source

ref(settings-debug-files): Convert to functional component (#73269)

Priscila Oliveira 5 days ago
parent
commit
c4b844e52a

+ 46 - 5
static/app/views/settings/projectDebugFiles/index.spec.tsx

@@ -36,19 +36,26 @@ describe('ProjectDebugFiles', function () {
       url: endpoint,
       body: [DebugFileFixture()],
     });
+    MockApiClient.addMockResponse({
+      url: `/organizations/${organization.slug}/builtin-symbol-sources/`,
+      method: 'GET',
+      body: [],
+    });
   });
 
-  it('renders', function () {
+  it('renders', async function () {
     render(<ProjectDebugFiles {...props} />);
 
     expect(screen.getByText('Debug Information Files')).toBeInTheDocument();
 
     // Uploaded debug files content
-    expect(screen.getByText('Uploaded debug information files')).toBeInTheDocument();
+    expect(
+      await screen.findByText('Uploaded debug information files')
+    ).toBeInTheDocument();
     expect(screen.getByText('libS.so')).toBeInTheDocument();
   });
 
-  it('renders empty', function () {
+  it('renders empty', async function () {
     MockApiClient.addMockResponse({
       url: endpoint,
       body: [],
@@ -58,7 +65,7 @@ describe('ProjectDebugFiles', function () {
 
     // Uploaded debug files content
     expect(
-      screen.getByText('There are no debug symbols for this project.')
+      await screen.findByText('There are no debug symbols for this project.')
     ).toBeInTheDocument();
   });
 
@@ -74,7 +81,7 @@ describe('ProjectDebugFiles', function () {
     renderGlobalModal();
 
     // Delete button
-    await userEvent.click(screen.getByTestId('delete-dif'));
+    await userEvent.click(await screen.findByTestId('delete-dif'));
 
     // Confirm Modal
     await screen.findByRole('dialog');
@@ -83,4 +90,38 @@ describe('ProjectDebugFiles', function () {
 
     expect(deleteMock).toHaveBeenCalled();
   });
+
+  it('display error if request for dsyms fails', async function () {
+    MockApiClient.addMockResponse({
+      url: endpoint,
+      body: [DebugFileFixture()],
+      statusCode: 400,
+    });
+
+    render(<ProjectDebugFiles {...props} />);
+
+    expect(await screen.findByText(/There was an error/)).toBeInTheDocument();
+
+    expect(screen.getByRole('button', {name: 'Retry'})).toBeInTheDocument();
+  });
+
+  it('display error if request for symbol sources fails', async function () {
+    MockApiClient.addMockResponse({
+      url: `/organizations/${organization.slug}/builtin-symbol-sources/`,
+      method: 'GET',
+      body: [],
+      statusCode: 400,
+    });
+
+    render(
+      <ProjectDebugFiles
+        {...props}
+        organization={{...organization, features: ['symbol-sources']}}
+      />
+    );
+
+    expect(await screen.findByText(/There was an error/)).toBeInTheDocument();
+
+    expect(screen.getByRole('button', {name: 'Retry'})).toBeInTheDocument();
+  });
 });

+ 217 - 189
static/app/views/settings/projectDebugFiles/index.tsx

@@ -1,20 +1,35 @@
-import {Fragment} from 'react';
+import {Fragment, useCallback, useState} from 'react';
 import type {RouteComponentProps} from 'react-router';
 import styled from '@emotion/styled';
 
-import {addErrorMessage} from 'sentry/actionCreators/indicator';
+import {
+  addErrorMessage,
+  addLoadingMessage,
+  addSuccessMessage,
+} from 'sentry/actionCreators/indicator';
 import Checkbox from 'sentry/components/checkbox';
+import LoadingError from 'sentry/components/loadingError';
+import LoadingIndicator from 'sentry/components/loadingIndicator';
 import Pagination from 'sentry/components/pagination';
 import {PanelTable} from 'sentry/components/panels/panelTable';
 import SearchBar from 'sentry/components/searchBar';
+import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
 import {t} from 'sentry/locale';
-import ProjectsStore from 'sentry/stores/projectsStore';
 import {space} from 'sentry/styles/space';
 import type {BuiltinSymbolSource, CustomRepo, DebugFile} from 'sentry/types/debugFiles';
 import type {Organization} from 'sentry/types/organization';
 import type {Project} from 'sentry/types/project';
+import {
+  type ApiQueryKey,
+  useApiQuery,
+  useMutation,
+  useQueryClient,
+} from 'sentry/utils/queryClient';
+import {decodeScalar} from 'sentry/utils/queryString';
+import type RequestError from 'sentry/utils/requestError/requestError';
 import routeTitleGen from 'sentry/utils/routeTitle';
-import DeprecatedAsyncView from 'sentry/views/deprecatedAsyncView';
+import useApi from 'sentry/utils/useApi';
+import {useNavigate} from 'sentry/utils/useNavigate';
 import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
 import TextBlock from 'sentry/views/settings/components/text/textBlock';
 import PermissionAlert from 'sentry/views/settings/project/permissionAlert';
@@ -27,158 +42,140 @@ type Props = RouteComponentProps<{projectId: string}, {}> & {
   project: Project;
 };
 
-type State = DeprecatedAsyncView['state'] & {
-  debugFiles: DebugFile[] | null;
-  project: Project;
-  showDetails: boolean;
-  builtinSymbolSources?: BuiltinSymbolSource[] | null;
-};
-
-class ProjectDebugSymbols extends DeprecatedAsyncView<Props, State> {
-  getTitle() {
-    const {projectId} = this.props.params;
-
-    return routeTitleGen(t('Debug Files'), projectId, false);
-  }
-
-  getDefaultState() {
-    return {
-      ...super.getDefaultState(),
-      project: this.props.project,
-      showDetails: false,
-    };
-  }
-
-  getEndpoints(): ReturnType<DeprecatedAsyncView['getEndpoints']> {
-    const {organization, params, location} = this.props;
-    const {builtinSymbolSources} = this.state || {};
-    const {query} = location.query;
-
-    const endpoints: ReturnType<DeprecatedAsyncView['getEndpoints']> = [
-      [
-        'debugFiles',
-        `/projects/${organization.slug}/${params.projectId}/files/dsyms/`,
-        {
-          query: {query},
-        },
-      ],
-    ];
-
-    if (!builtinSymbolSources && organization.features.includes('symbol-sources')) {
-      endpoints.push([
-        'builtinSymbolSources',
-        `/organizations/${organization.slug}/builtin-symbol-sources/`,
-        {},
-      ]);
-    }
-
-    return endpoints;
-  }
-
-  handleDelete = (id: string) => {
-    const {organization, params} = this.props;
-
-    this.setState({
-      loading: true,
-    });
-
-    this.api.request(
-      `/projects/${organization.slug}/${params.projectId}/files/dsyms/?id=${id}`,
-      {
-        method: 'DELETE',
-        complete: () => this.fetchData(),
-      }
-    );
-  };
-
-  handleSearch = (query: string) => {
-    const {location, router} = this.props;
-
-    router.push({
-      ...location,
-      query: {...location.query, cursor: undefined, query},
-    });
-  };
-
-  async fetchProject() {
-    const {organization, params} = this.props;
-    try {
-      const updatedProject = await this.api.requestPromise(
-        `/projects/${organization.slug}/${params.projectId}/`
-      );
-      ProjectsStore.onUpdateSuccess(updatedProject);
-    } catch {
-      addErrorMessage(t('An error occurred while fetching project data'));
-    }
-  }
-
-  getQuery() {
-    const {query} = this.props.location.query;
+function makeDebugFilesQueryKey({
+  orgSlug,
+  projectSlug,
+  query,
+}: {
+  orgSlug: string;
+  projectSlug: string;
+  query: string;
+}): ApiQueryKey {
+  return [
+    `/projects/${orgSlug}/${projectSlug}/files/dsyms/`,
+    {
+      query: {query},
+    },
+  ];
+}
 
-    return typeof query === 'string' ? query : undefined;
-  }
+function makeSymbolSourcesQueryKey({orgSlug}: {orgSlug: string}): ApiQueryKey {
+  return [`/organizations/${orgSlug}/builtin-symbol-sources/`];
+}
 
-  getEmptyMessage() {
-    if (this.getQuery()) {
-      return t('There are no debug symbols that match your search.');
+function ProjectDebugSymbols({organization, project, location, router, params}: Props) {
+  const navigate = useNavigate();
+  const api = useApi();
+  const queryClient = useQueryClient();
+  const [showDetails, setShowDetails] = useState(false);
+
+  const query = decodeScalar(location.query.query, '');
+  const hasSymbolSourcesFeatureFlag = organization.features.includes('symbol-sources');
+
+  const {
+    data: debugFiles,
+    getResponseHeader: getDebugFilesResponseHeader,
+    isLoading: isLoadingDebugFiles,
+    isLoadingError: isLoadingErrorDebugFiles,
+    refetch: refetchDebugFiles,
+  } = useApiQuery<DebugFile[] | null>(
+    makeDebugFilesQueryKey({
+      projectSlug: params.projectId,
+      orgSlug: organization.slug,
+      query,
+    }),
+    {
+      staleTime: 0,
+      retry: false,
     }
-
-    return t('There are no debug symbols for this project.');
-  }
-
-  renderLoading() {
-    return this.renderBody();
-  }
-
-  renderDebugFiles() {
-    const {debugFiles, showDetails} = this.state;
-    const {organization, params, project} = this.props;
-
-    if (!debugFiles?.length) {
-      return null;
+  );
+
+  const {
+    data: builtinSymbolSources,
+    isLoading: isLoadingSymbolSources,
+    isError: isErrorSymbolSources,
+    refetch: refetchSymbolSources,
+  } = useApiQuery<BuiltinSymbolSource[] | null>(
+    makeSymbolSourcesQueryKey({orgSlug: organization.slug}),
+    {
+      staleTime: 0,
+      enabled: hasSymbolSourcesFeatureFlag,
+      retry: 0,
     }
-
-    return debugFiles.map(debugFile => {
-      const downloadUrl = `${this.api.baseUrl}/projects/${organization.slug}/${params.projectId}/files/dsyms/?id=${debugFile.id}`;
-
-      return (
-        <DebugFileRow
-          debugFile={debugFile}
-          showDetails={showDetails}
-          downloadUrl={downloadUrl}
-          downloadRole={organization.debugFilesRole}
-          onDelete={this.handleDelete}
-          key={debugFile.id}
-          orgSlug={organization.slug}
-          project={project}
-        />
+  );
+
+  const handleSearch = useCallback(
+    (value: string) => {
+      navigate({
+        ...location,
+        query: {...location.query, cursor: undefined, query: !value ? undefined : value},
+      });
+    },
+    [navigate, location]
+  );
+
+  const {mutate: handleDeleteDebugFile} = useMutation<unknown, RequestError, string>({
+    mutationFn: (id: string) => {
+      return api.requestPromise(
+        `/projects/${organization.slug}/${params.projectId}/files/dsyms/?id=${id}`,
+        {
+          method: 'DELETE',
+        }
+      );
+    },
+    onMutate: () => {
+      addLoadingMessage('Deleting debug file');
+    },
+    onSuccess: () => {
+      addSuccessMessage('Successfully deleted debug file');
+
+      // invalidate debug files query
+      queryClient.invalidateQueries(
+        makeDebugFilesQueryKey({
+          projectSlug: params.projectId,
+          orgSlug: organization.slug,
+          query,
+        })
       );
-    });
-  }
-
-  renderBody() {
-    const {organization, project, router, location} = this.props;
-    const {loading, showDetails, builtinSymbolSources, debugFiles, debugFilesPageLinks} =
-      this.state;
-
-    return (
-      <Fragment>
-        <SettingsPageHeader title={t('Debug Information Files')} />
-
-        <TextBlock>
-          {t(`
-            Debug information files are used to convert addresses and minified
-            function names from native crash reports into function names and
-            locations.
-          `)}
-        </TextBlock>
-
-        {organization.features.includes('symbol-sources') && (
-          <Fragment>
-            <PermissionAlert project={project} />
 
+      // invalidate symbol sources query
+      queryClient.invalidateQueries(
+        makeSymbolSourcesQueryKey({
+          orgSlug: organization.slug,
+        })
+      );
+    },
+    onError: () => {
+      addErrorMessage('Failed to delete debug file');
+    },
+  });
+
+  return (
+    <SentryDocumentTitle title={routeTitleGen(t('Debug Files'), params.projectId, false)}>
+      <SettingsPageHeader title={t('Debug Information Files')} />
+
+      <TextBlock>
+        {t(`
+          Debug information files are used to convert addresses and minified
+          function names from native crash reports into function names and
+          locations.
+        `)}
+      </TextBlock>
+
+      {organization.features.includes('symbol-sources') && (
+        <Fragment>
+          <PermissionAlert project={project} />
+
+          {isLoadingSymbolSources ? (
+            <LoadingIndicator />
+          ) : isErrorSymbolSources ? (
+            <LoadingError
+              onRetry={refetchSymbolSources}
+              message={t('There was an error loading repositories.')}
+            />
+          ) : (
             <Sources
-              api={this.api}
+              api={api}
               location={location}
               router={router}
               project={project}
@@ -190,48 +187,79 @@ class ProjectDebugSymbols extends DeprecatedAsyncView<Props, State> {
               }
               builtinSymbolSources={project.builtinSymbolSources ?? []}
               builtinSymbolSourceOptions={builtinSymbolSources ?? []}
-              isLoading={loading}
             />
-          </Fragment>
-        )}
-
-        <Wrapper>
-          <TextBlock noMargin>{t('Uploaded debug information files')}</TextBlock>
-          <Filters>
-            <Label>
-              <Checkbox
-                checked={showDetails}
-                onChange={e => {
-                  this.setState({showDetails: (e.target as HTMLInputElement).checked});
-                }}
+          )}
+        </Fragment>
+      )}
+
+      {isLoadingDebugFiles ? (
+        <LoadingIndicator />
+      ) : isLoadingErrorDebugFiles ? (
+        <LoadingError
+          onRetry={refetchDebugFiles}
+          message={t('There was an error loading debug information files.')}
+        />
+      ) : (
+        <Fragment>
+          <Wrapper>
+            <TextBlock noMargin>{t('Uploaded debug information files')}</TextBlock>
+            <Filters>
+              <Label>
+                <Checkbox
+                  checked={showDetails}
+                  onChange={e => {
+                    setShowDetails((e.target as HTMLInputElement).checked);
+                  }}
+                />
+                {t('show details')}
+              </Label>
+
+              <SearchBar
+                placeholder={t('Search DIFs')}
+                onSearch={handleSearch}
+                query={query}
               />
-              {t('show details')}
-            </Label>
-
-            <SearchBar
-              placeholder={t('Search DIFs')}
-              onSearch={this.handleSearch}
-              query={this.getQuery()}
-            />
-          </Filters>
-        </Wrapper>
-
-        <StyledPanelTable
-          headers={[
-            t('Debug ID'),
-            t('Information'),
-            <Actions key="actions">{t('Actions')}</Actions>,
-          ]}
-          emptyMessage={this.getEmptyMessage()}
-          isEmpty={debugFiles?.length === 0}
-          isLoading={loading}
-        >
-          {this.renderDebugFiles()}
-        </StyledPanelTable>
-        <Pagination pageLinks={debugFilesPageLinks} />
-      </Fragment>
-    );
-  }
+            </Filters>
+          </Wrapper>
+
+          <StyledPanelTable
+            headers={[
+              t('Debug ID'),
+              t('Information'),
+              <Actions key="actions">{t('Actions')}</Actions>,
+            ]}
+            emptyMessage={
+              query
+                ? t('There are no debug symbols that match your search.')
+                : t('There are no debug symbols for this project.')
+            }
+            isEmpty={debugFiles?.length === 0}
+            isLoading={isLoadingDebugFiles}
+          >
+            {!debugFiles?.length
+              ? null
+              : debugFiles.map(debugFile => {
+                  const downloadUrl = `${api.baseUrl}/projects/${organization.slug}/${params.projectId}/files/dsyms/?id=${debugFile.id}`;
+
+                  return (
+                    <DebugFileRow
+                      debugFile={debugFile}
+                      showDetails={showDetails}
+                      downloadUrl={downloadUrl}
+                      downloadRole={organization.debugFilesRole}
+                      onDelete={handleDeleteDebugFile}
+                      key={debugFile.id}
+                      orgSlug={organization.slug}
+                      project={project}
+                    />
+                  );
+                })}
+          </StyledPanelTable>
+          <Pagination pageLinks={getDebugFilesResponseHeader?.('Link')} />
+        </Fragment>
+      )}
+    </SentryDocumentTitle>
+  );
 }
 
 const StyledPanelTable = styled(PanelTable)`

+ 0 - 1
static/app/views/settings/projectDebugFiles/sources/builtInRepositories.spec.tsx

@@ -18,7 +18,6 @@ describe('Built-in Repositories', function () {
         api={api}
         organization={organization}
         project={project}
-        isLoading={false}
         builtinSymbolSourceOptions={builtinSymbolSourceOptions}
         builtinSymbolSources={builtinSymbolSources}
       />

+ 31 - 38
static/app/views/settings/projectDebugFiles/sources/builtInRepositories.tsx

@@ -4,7 +4,6 @@ import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicato
 import type {Client} from 'sentry/api';
 import Access from 'sentry/components/acl/access';
 import SelectField from 'sentry/components/forms/fields/selectField';
-import LoadingIndicator from 'sentry/components/loadingIndicator';
 import Panel from 'sentry/components/panels/panel';
 import PanelBody from 'sentry/components/panels/panelBody';
 import PanelHeader from 'sentry/components/panels/panelHeader';
@@ -20,7 +19,6 @@ type Props = {
   api: Client;
   builtinSymbolSourceOptions: BuiltinSymbolSource[];
   builtinSymbolSources: string[];
-  isLoading: boolean;
   organization: Organization;
   project: Project;
 };
@@ -31,7 +29,6 @@ function BuiltInRepositories({
   builtinSymbolSourceOptions,
   builtinSymbolSources,
   project,
-  isLoading,
 }: Props) {
   // If the project details object has an unknown built-in source, this will be filtered here.
   // This prevents the UI from showing the wrong feedback message when updating the field
@@ -84,41 +81,37 @@ function BuiltInRepositories({
     <Panel>
       <PanelHeader>{SECTION_TITLE}</PanelHeader>
       <PanelBody>
-        {isLoading ? (
-          <LoadingIndicator />
-        ) : (
-          <Access access={['project:write']} project={project}>
-            {({hasAccess}) => (
-              <StyledSelectField
-                disabledReason={
-                  !hasAccess
-                    ? t(
-                        'You do not have permission to edit built-in repositories configurations.'
-                      )
-                    : undefined
-                }
-                disabled={!hasAccess}
-                name="builtinSymbolSources"
-                label={SECTION_TITLE}
-                help={t(
-                  'Configures which built-in repositories Sentry should use to resolve debug files.'
-                )}
-                placeholder={t('Select built-in repository')}
-                value={validBuiltInSymbolSources}
-                onChange={handleChange}
-                options={builtinSymbolSourceOptions
-                  .filter(source => !source.hidden)
-                  .map(source => ({
-                    value: source.sentry_key,
-                    label: source.name,
-                  }))}
-                getValue={value => (value === null ? [] : value)}
-                flexibleControlStateSize
-                multiple
-              />
-            )}
-          </Access>
-        )}
+        <Access access={['project:write']} project={project}>
+          {({hasAccess}) => (
+            <StyledSelectField
+              disabledReason={
+                !hasAccess
+                  ? t(
+                      'You do not have permission to edit built-in repositories configurations.'
+                    )
+                  : undefined
+              }
+              disabled={!hasAccess}
+              name="builtinSymbolSources"
+              label={SECTION_TITLE}
+              help={t(
+                'Configures which built-in repositories Sentry should use to resolve debug files.'
+              )}
+              placeholder={t('Select built-in repository')}
+              value={validBuiltInSymbolSources}
+              onChange={handleChange}
+              options={builtinSymbolSourceOptions
+                .filter(source => !source.hidden)
+                .map(source => ({
+                  value: source.sentry_key,
+                  label: source.name,
+                }))}
+              getValue={value => (value === null ? [] : value)}
+              flexibleControlStateSize
+              multiple
+            />
+          )}
+        </Access>
       </PanelBody>
     </Panel>
   );

+ 2 - 7
static/app/views/settings/projectDebugFiles/sources/customRepositories/index.tsx

@@ -11,7 +11,6 @@ import Feature from 'sentry/components/acl/feature';
 import DropdownAutoComplete from 'sentry/components/dropdownAutoComplete';
 import DropdownButton from 'sentry/components/dropdownButton';
 import EmptyStateWarning from 'sentry/components/emptyStateWarning';
-import LoadingIndicator from 'sentry/components/loadingIndicator';
 import MenuItem from 'sentry/components/menuItem';
 import Panel from 'sentry/components/panels/panel';
 import PanelBody from 'sentry/components/panels/panelBody';
@@ -35,7 +34,6 @@ const SECTION_TITLE = t('Custom Repositories');
 type Props = {
   api: Client;
   customRepositories: CustomRepo[];
-  isLoading: boolean;
   location: Location;
   organization: Organization;
   project: Project;
@@ -49,7 +47,6 @@ function CustomRepositories({
   project,
   router,
   location,
-  isLoading,
 }: Props) {
   const appStoreConnectContext = useContext(AppStoreConnectContext);
 
@@ -214,7 +211,7 @@ function CustomRepositories({
       {({hasFeature}) => (
         <Access access={['project:write']} project={project}>
           {({hasAccess}) => {
-            const addRepositoryButtonDisabled = !hasAccess || isLoading;
+            const addRepositoryButtonDisabled = !hasAccess;
             return (
               <Panel>
                 <PanelHeader hasButtons>
@@ -258,9 +255,7 @@ function CustomRepositories({
                   </Tooltip>
                 </PanelHeader>
                 <PanelBody>
-                  {isLoading ? (
-                    <LoadingIndicator />
-                  ) : !repositories.length ? (
+                  {!repositories.length ? (
                     <EmptyStateWarning>
                       <p>{t('No custom repositories configured')}</p>
                     </EmptyStateWarning>

+ 0 - 4
static/app/views/settings/projectDebugFiles/sources/index.tsx

@@ -15,7 +15,6 @@ type Props = {
   builtinSymbolSourceOptions: BuiltinSymbolSource[];
   builtinSymbolSources: string[];
   customRepositories: CustomRepo[];
-  isLoading: boolean;
   location: Location;
   organization: Organization;
   project: Project;
@@ -31,7 +30,6 @@ function Sources({
   project,
   location,
   router,
-  isLoading,
 }: Props) {
   return (
     <Fragment>
@@ -41,7 +39,6 @@ function Sources({
         builtinSymbolSources={builtinSymbolSources}
         builtinSymbolSourceOptions={builtinSymbolSourceOptions}
         project={project}
-        isLoading={isLoading}
       />
       <CustomRepositories
         api={api}
@@ -50,7 +47,6 @@ function Sources({
         organization={organization}
         customRepositories={customRepositories}
         project={project}
-        isLoading={isLoading}
       />
     </Fragment>
   );