Browse Source

feat(source-maps): Emphasize release info (#57576)

* Implement new UI for artifact bundles

Closes https://github.com/getsentry/sentry/issues/56380
Requires https://github.com/getsentry/sentry/pull/57573
ArthurKnaus 1 year ago
parent
commit
595584463d

+ 3 - 0
fixtures/js-stubs/sourceMapsDebugIDBundlesArtifacts.ts

@@ -15,6 +15,9 @@ export function SourceMapsDebugIDBundlesArtifacts(
         dist: ['android', 'iOS'],
       },
     ],
+    fileCount: 22,
+    date: '2023-03-08T09:53:09Z',
+    dateModified: '2021-08-25T23:10:00.000Z',
     files: [
       {
         id: 'ZmlsZXMvXy9fL21haW4uanM=',

+ 8 - 1
static/app/components/events/interfaces/keyValueList/index.tsx

@@ -1,5 +1,6 @@
 import {Fragment} from 'react';
 import styled from '@emotion/styled';
+import classNames from 'classnames';
 import sortBy from 'lodash/sortBy';
 
 import {space} from 'sentry/styles/space';
@@ -10,6 +11,7 @@ import theme from 'sentry/utils/theme';
 import {Value, ValueProps} from './value';
 
 interface Props extends Pick<ValueProps, 'raw' | 'isContextData'> {
+  className?: string;
   data?: KeyValueListData;
   longKeys?: boolean;
   onClick?: () => void;
@@ -23,6 +25,7 @@ function KeyValueList({
   raw = false,
   longKeys = false,
   onClick,
+  className,
   ...props
 }: Props) {
   if (!defined(data) || data.length === 0) {
@@ -32,7 +35,11 @@ function KeyValueList({
   const keyValueData = shouldSort ? sortBy(data, [({key}) => key.toLowerCase()]) : data;
 
   return (
-    <Table className="table key-value" onClick={onClick} {...props}>
+    <Table
+      className={classNames('table key-value', className)}
+      onClick={onClick}
+      {...props}
+    >
       <tbody>
         {keyValueData.map(
           (

+ 3 - 0
static/app/types/sourceMaps.ts

@@ -14,6 +14,9 @@ export type DebugIdBundle = {
 export type DebugIdBundleArtifact = {
   associations: DebugIdBundleAssociation[];
   bundleId: string;
+  date: string;
+  dateModified: string;
+  fileCount: number;
   files: {
     debugId: string;
     filePath: string;

+ 0 - 132
static/app/views/settings/projectSourceMaps/associations.tsx

@@ -1,132 +0,0 @@
-import {Link} from 'react-router';
-import styled from '@emotion/styled';
-
-import ClippedBox from 'sentry/components/clippedBox';
-import {CopyToClipboardButton} from 'sentry/components/copyToClipboardButton';
-import {Hovercard} from 'sentry/components/hovercard';
-import Placeholder from 'sentry/components/placeholder';
-import TextOverflow from 'sentry/components/textOverflow';
-import {t, tct, tn} from 'sentry/locale';
-import {space} from 'sentry/styles/space';
-import {DebugIdBundleAssociation} from 'sentry/types/sourceMaps';
-import useOrganization from 'sentry/utils/useOrganization';
-
-function AssociationsBody({associations}: {associations: DebugIdBundleAssociation[]}) {
-  const organization = useOrganization();
-
-  return (
-    <ClippedBoxWithoutPadding
-      clipHeight={210}
-      btnText={t('+ %s more', associations.length - associations.slice(0, 4).length)}
-      buttonProps={{
-        priority: 'default',
-        borderless: true,
-      }}
-    >
-      <NumericList>
-        {associations.map(({release, dist}) => (
-          <li key={release}>
-            <ReleaseContent>
-              <ReleaseLink
-                to={`/organizations/${organization.slug}/releases/${release}/`}
-              >
-                <TextOverflow>{release}</TextOverflow>
-              </ReleaseLink>
-              <CopyToClipboardButton
-                text={release}
-                borderless
-                size="zero"
-                iconSize="sm"
-              />
-            </ReleaseContent>
-            {!dist?.length ? (
-              <NoAssociations>
-                {t('No dists associated with this release')}
-              </NoAssociations>
-            ) : (
-              tct('Dist: [dist]', {
-                dist: typeof dist === 'string' ? dist : dist.join(', '),
-              })
-            )}
-          </li>
-        ))}
-      </NumericList>
-    </ClippedBoxWithoutPadding>
-  );
-}
-
-type Props = {
-  associations?: DebugIdBundleAssociation[];
-  loading?: boolean;
-};
-
-export function Associations({associations = [], loading}: Props) {
-  if (loading) {
-    return <Placeholder width="200px" height="20px" />;
-  }
-
-  if (!associations.length) {
-    return (
-      <NoAssociations>{t('No releases associated with this bundle')}</NoAssociations>
-    );
-  }
-
-  return (
-    <div>
-      <WiderHovercard
-        position="right"
-        body={<AssociationsBody associations={associations} />}
-        header={t('Releases')}
-        displayTimeout={0}
-        showUnderline
-      >
-        {tn('%s Release', '%s Releases', associations.length)}
-      </WiderHovercard>{' '}
-      {t('associated')}
-    </div>
-  );
-}
-
-const NoAssociations = styled('div')`
-  color: ${p => p.theme.disabled};
-`;
-
-const ReleaseContent = styled('div')`
-  display: grid;
-  grid-template-columns: 1fr max-content;
-  gap: ${space(1)};
-  align-items: center;
-`;
-
-const ReleaseLink = styled(Link)`
-  overflow: hidden;
-`;
-
-// TODO(ui): Add a native numeric list to the List component
-const NumericList = styled('ol')`
-  display: flex;
-  flex-direction: column;
-  gap: ${space(0.5)};
-  margin: 0;
-`;
-
-const WiderHovercard = styled(Hovercard)`
-  width: 320px;
-  /* "Body" element */
-  > div:last-child {
-    transition: all 5s ease-in-out;
-    overflow-x: hidden;
-    overflow-y: scroll;
-    max-height: 300px;
-  }
-`;
-
-const ClippedBoxWithoutPadding = styled(ClippedBox)`
-  padding: 0;
-  /* "ClipFade" element */
-  > div:last-child {
-    background: ${p => p.theme.background};
-    border-bottom: 0;
-    padding: 0;
-  }
-`;

+ 37 - 0
static/app/views/settings/projectSourceMaps/debugIdBundleDeleteButton.tsx

@@ -0,0 +1,37 @@
+import Access from 'sentry/components/acl/access';
+import {Button, ButtonProps} from 'sentry/components/button';
+import Confirm from 'sentry/components/confirm';
+import {Tooltip} from 'sentry/components/tooltip';
+import {IconDelete} from 'sentry/icons';
+import {t} from 'sentry/locale';
+
+interface DebugIdBundleDeleteButtonProps {
+  onDelete: () => void;
+  size?: ButtonProps['size'];
+}
+
+export function DebugIdBundleDeleteButton({
+  onDelete,
+  size = 'xs',
+}: DebugIdBundleDeleteButtonProps) {
+  return (
+    <Access access={['project:releases']}>
+      {({hasAccess}) => (
+        <Tooltip
+          disabled={hasAccess}
+          title={t('You do not have permission to delete bundles.')}
+        >
+          <Confirm
+            onConfirm={onDelete}
+            message={t('Are you sure you want to delete this bundle?')}
+            disabled={!hasAccess}
+          >
+            <Button icon={<IconDelete size="xs" />} size={size} disabled={!hasAccess}>
+              {t('Delete Bundle')}
+            </Button>
+          </Confirm>
+        </Tooltip>
+      )}
+    </Access>
+  );
+}

+ 90 - 0
static/app/views/settings/projectSourceMaps/debugIdBundleDetails.tsx

@@ -0,0 +1,90 @@
+import {Fragment, useMemo, useState} from 'react';
+import styled from '@emotion/styled';
+
+import {Button} from 'sentry/components/button';
+import DateTime from 'sentry/components/dateTime';
+import KeyValueList from 'sentry/components/events/interfaces/keyValueList';
+import Link from 'sentry/components/links/link';
+import {t} from 'sentry/locale';
+import {KeyValueListData} from 'sentry/types';
+import {DebugIdBundle, DebugIdBundleArtifact} from 'sentry/types/sourceMaps';
+import useOrganization from 'sentry/utils/useOrganization';
+
+const formatDist = (dist: string | string[] | null) => {
+  if (Array.isArray(dist)) {
+    return dist.join(', ');
+  }
+  if (dist === null) {
+    return 'none';
+  }
+  return dist;
+};
+
+export function DebugIdBundleDetails({
+  debugIdBundle,
+}: {
+  debugIdBundle: DebugIdBundle | DebugIdBundleArtifact;
+}) {
+  const [showAll, setShowAll] = useState(false);
+  const organization = useOrganization();
+  const detailsData = useMemo<KeyValueListData>(() => {
+    const associations = debugIdBundle.associations;
+    const visibleAssociations = showAll ? associations : associations.slice(0, 3);
+    return [
+      {
+        key: 'count',
+        subject: t('Artifacts'),
+        value: debugIdBundle.fileCount,
+      },
+      {
+        key: 'releases',
+        subject: t('Associated Releases'),
+        actionButton: associations.length > 3 && (
+          <Button size="xs" onClick={() => setShowAll(value => !value)}>
+            {showAll ? t('Show Less') : t('Show All')}
+          </Button>
+        ),
+        value:
+          associations.length > 0 ? (
+            <ReleasesWrapper className="val-string-multiline">
+              {visibleAssociations.map(association => (
+                <Fragment key={association.release}>
+                  <Link
+                    to={`/organizations/${organization.slug}/releases/${association.release}/`}
+                  >
+                    {association.release}
+                  </Link>
+                  {` (Dist: ${formatDist(association.dist)})`}
+                  <br />
+                </Fragment>
+              ))}
+            </ReleasesWrapper>
+          ) : (
+            t('No releases associated with this bundle')
+          ),
+      },
+      {
+        key: 'date',
+        subject: t('Date Uploaded'),
+        value: (
+          <pre>
+            <DateTime timeZone year date={debugIdBundle.date} />
+          </pre>
+        ),
+      },
+    ];
+  }, [debugIdBundle, organization.slug, showAll]);
+
+  return <StyledKeyValueList data={detailsData} shouldSort={false} />;
+}
+
+const ReleasesWrapper = styled('pre')`
+  max-height: 200px;
+  overflow-y: auto !important;
+`;
+
+const StyledKeyValueList = styled(KeyValueList)`
+  && {
+    margin-bottom: 0;
+  }
+`;

+ 93 - 0
static/app/views/settings/projectSourceMaps/debugIdBundleList.tsx

@@ -0,0 +1,93 @@
+import {Link} from 'react-router';
+import styled from '@emotion/styled';
+
+import EmptyMessage from 'sentry/components/emptyMessage';
+import LoadingIndicator from 'sentry/components/loadingIndicator';
+import Panel from 'sentry/components/panels/panel';
+import {IconList} from 'sentry/icons';
+import {space} from 'sentry/styles/space';
+import {DebugIdBundle, Project} from 'sentry/types';
+import useOrganization from 'sentry/utils/useOrganization';
+import {DebugIdBundleDeleteButton} from 'sentry/views/settings/projectSourceMaps/debugIdBundleDeleteButton';
+import {DebugIdBundleDetails} from 'sentry/views/settings/projectSourceMaps/debugIdBundleDetails';
+
+interface DebugIdBundleListProps {
+  emptyMessage: React.ReactNode;
+  isLoading: boolean;
+  onDelete: (bundleId: string) => void;
+  project: Project;
+  debugIdBundles?: DebugIdBundle[];
+}
+
+export function DebugIdBundleList({
+  isLoading,
+  debugIdBundles,
+  emptyMessage,
+  onDelete,
+  project,
+}: DebugIdBundleListProps) {
+  const organization = useOrganization();
+
+  if (isLoading) {
+    return <LoadingIndicator />;
+  }
+
+  if (!debugIdBundles || debugIdBundles.length === 0) {
+    return <EmptyMessage>{emptyMessage}</EmptyMessage>;
+  }
+  return (
+    <List>
+      {debugIdBundles.map(debugIdBundle => (
+        <Item key={debugIdBundle.bundleId}>
+          <ItemHeader>
+            <ItemTitle
+              to={`/settings/${organization.slug}/projects/${
+                project.slug
+              }/source-maps/artifact-bundles/${encodeURIComponent(
+                debugIdBundle.bundleId
+              )}`}
+            >
+              <IconList /> {debugIdBundle.bundleId}
+            </ItemTitle>
+            <DebugIdBundleDeleteButton
+              onDelete={() => onDelete(debugIdBundle.bundleId)}
+            />
+          </ItemHeader>
+          <ItemContent>
+            <DebugIdBundleDetails debugIdBundle={debugIdBundle} />
+          </ItemContent>
+        </Item>
+      ))}
+    </List>
+  );
+}
+
+const List = styled('div')`
+  display: grid;
+  grid-template-columns: 1fr;
+  gap: ${space(2)};
+`;
+
+const Item = styled(Panel)`
+  margin: 0;
+`;
+
+const ItemHeader = styled('div')`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  font-size: ${p => p.theme.fontSizeMedium};
+  border-bottom: 1px solid ${p => p.theme.border};
+  line-height: 1;
+  padding: ${space(1)} ${space(2)};
+`;
+
+const ItemTitle = styled(Link)`
+  display: flex;
+  align-items: center;
+  gap: ${space(1)};
+`;
+
+const ItemContent = styled('div')`
+  padding: ${space(1)} ${space(2)};
+`;

+ 30 - 80
static/app/views/settings/projectSourceMaps/projectSourceMaps.spec.tsx

@@ -220,6 +220,7 @@ describe('ProjectSourceMaps', function () {
         context: routerContext,
         organization,
       });
+      expect(mockRequests.artifactBundles).toHaveBeenCalledTimes(1);
 
       // Title
       expect(screen.getByRole('heading', {name: 'Source Maps'})).toBeInTheDocument();
@@ -241,91 +242,42 @@ describe('ProjectSourceMaps', function () {
         screen.getByPlaceholderText('Filter by Bundle ID, Debug ID or Release')
       ).toBeInTheDocument();
 
-      // Date Uploaded can be sorted
-      await userEvent.click(screen.getByTestId('date-uploaded-header'));
-      await userEvent.hover(screen.getByTestId('icon-arrow'));
-      expect(await screen.findByText('Switch to ascending order')).toBeInTheDocument();
-      await userEvent.click(screen.getByTestId('icon-arrow'));
-      await waitFor(() => {
-        expect(mockRequests.artifactBundles).toHaveBeenLastCalledWith(
-          '/projects/org-slug/project-slug/files/artifact-bundles/',
-          expect.objectContaining({
-            query: expect.objectContaining({
-              sortBy: 'date_added',
-            }),
-          })
-        );
-      });
-
-      // Date Uploaded can be sorted in descending
-      await userEvent.hover(screen.getByTestId('icon-arrow'));
-      expect(await screen.findByText('Switch to descending order')).toBeInTheDocument();
-      await userEvent.click(screen.getByTestId('icon-arrow'));
-      await waitFor(() => {
-        expect(mockRequests.artifactBundles).toHaveBeenLastCalledWith(
-          '/projects/org-slug/project-slug/files/artifact-bundles/',
-          expect.objectContaining({
-            query: expect.objectContaining({
-              sortBy: '-date_added',
-            }),
-          })
-        );
-      });
-
-      // Date Modified can be sorted
-      await userEvent.click(screen.getByTestId('date-modified-header'));
-      await userEvent.hover(screen.getByTestId('icon-arrow-modified'));
-      expect(await screen.findByText('Switch to ascending order')).toBeInTheDocument();
-      await userEvent.click(screen.getByTestId('icon-arrow-modified'));
-
-      await waitFor(() => {
-        expect(mockRequests.artifactBundles).toHaveBeenLastCalledWith(
-          '/projects/org-slug/project-slug/files/artifact-bundles/',
-          expect.objectContaining({
-            query: expect.objectContaining({
-              sortBy: 'date_modified',
-            }),
-          })
-        );
-      });
-
-      // Date Modified can be sorted in descending
-      await userEvent.hover(screen.getByTestId('icon-arrow-modified'));
-      expect(await screen.findByText('Switch to descending order')).toBeInTheDocument();
-      await userEvent.click(screen.getByTestId('icon-arrow-modified'));
-
-      await waitFor(() => {
-        expect(mockRequests.artifactBundles).toHaveBeenLastCalledWith(
-          '/projects/org-slug/project-slug/files/artifact-bundles/',
-          expect.objectContaining({
-            query: expect.objectContaining({
-              sortBy: '-date_modified',
-            }),
-          })
-        );
-      });
-
       // Artifacts
-      expect(screen.getByText('39')).toBeInTheDocument();
-      // Date Modified
-      expect(screen.getByText('Mar 10, 2023 8:25 AM UTC')).toBeInTheDocument();
-      // Date Uploaded
-      expect(screen.getByText('Mar 8, 2023 9:53 AM UTC')).toBeInTheDocument();
-      // Delete button
-      expect(screen.getByRole('button', {name: 'Remove All Artifacts'})).toBeEnabled();
-
+      expect(await screen.findByText('Artifacts')).toBeInTheDocument();
+      expect(await screen.findByText('39')).toBeInTheDocument();
       // Release information
+      expect(await screen.findByText('Associated Releases')).toBeInTheDocument();
       expect(
-        await screen.findByText(textWithMarkupMatcher('2 Releases associated'))
+        await screen.findByText(textWithMarkupMatcher('v2.0 (Dist: none)'))
       ).toBeInTheDocument();
-      await userEvent.hover(screen.getByText('2 Releases'));
       expect(
-        await screen.findByText('frontend@2e318148eac9298ec04a662ae32b4b093b027f0a')
+        await screen.findByText(
+          textWithMarkupMatcher(
+            'frontend@2e318148eac9298ec04a662ae32b4b093b027f0a (Dist: android, iOS)'
+          )
+        )
       ).toBeInTheDocument();
+      // Date Uploaded
+      expect(await screen.findByText('Date Uploaded')).toBeInTheDocument();
+      expect(screen.getByText('Mar 8, 2023 9:53 AM UTC')).toBeInTheDocument();
+      // Delete button
+      expect(screen.getByRole('button', {name: 'Delete Bundle'})).toBeEnabled();
+
+      // Click on release
+      await userEvent.click(
+        screen.getByRole('link', {
+          name: 'frontend@2e318148eac9298ec04a662ae32b4b093b027f0a',
+        })
+      );
+      expect(router.push).toHaveBeenLastCalledWith(
+        '/organizations/org-slug/releases/frontend@2e318148eac9298ec04a662ae32b4b093b027f0a/'
+      );
 
       // Click on bundle id
       await userEvent.click(
-        screen.getByRole('link', {name: 'b916a646-2c6b-4e45-af4c-409830a44e0e'})
+        screen.getByRole('link', {
+          name: 'b916a646-2c6b-4e45-af4c-409830a44e0e',
+        })
       );
       expect(router.push).toHaveBeenLastCalledWith(
         '/settings/org-slug/projects/project-slug/source-maps/artifact-bundles/b916a646-2c6b-4e45-af4c-409830a44e0e'
@@ -334,11 +286,9 @@ describe('ProjectSourceMaps', function () {
       renderGlobalModal();
 
       // Delete item displays a confirmation modal
-      await userEvent.click(screen.getByRole('button', {name: 'Remove All Artifacts'}));
+      await userEvent.click(screen.getByRole('button', {name: 'Delete Bundle'}));
       expect(
-        await screen.findByText(
-          'Are you sure you want to remove all artifacts in this archive?'
-        )
+        await screen.findByText('Are you sure you want to delete this bundle?')
       ).toBeInTheDocument();
       // Close modal
       await userEvent.click(screen.getByRole('button', {name: 'Confirm'}));

+ 56 - 77
static/app/views/settings/projectSourceMaps/projectSourceMaps.tsx

@@ -32,7 +32,8 @@ import useOrganization from 'sentry/utils/useOrganization';
 import {normalizeUrl} from 'sentry/utils/withDomainRequired';
 import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
 import TextBlock from 'sentry/views/settings/components/text/textBlock';
-import {Associations} from 'sentry/views/settings/projectSourceMaps/associations';
+import {DebugIdBundleList} from 'sentry/views/settings/projectSourceMaps/debugIdBundleList';
+import {useDeleteDebugIdBundle} from 'sentry/views/settings/projectSourceMaps/useDeleteDebugIdBundle';
 
 enum SortBy {
   ASC_ADDED = 'date_added',
@@ -209,7 +210,7 @@ export function ProjectSourceMaps({location, router, project}: Props) {
     [
       debugIdBundlesEndpoint,
       {
-        query: {query, cursor, sortBy},
+        query: {query, cursor, sortBy: SortBy.DESC_MODIFIED},
       },
     ],
     {
@@ -219,6 +220,10 @@ export function ProjectSourceMaps({location, router, project}: Props) {
     }
   );
 
+  const {mutate: deleteDebugIdBundle} = useDeleteDebugIdBundle({
+    onSuccess: () => debugIdBundlesRefetch(),
+  });
+
   const handleSearch = useCallback(
     (newQuery: string) => {
       router.push({
@@ -256,31 +261,21 @@ export function ProjectSourceMaps({location, router, project}: Props) {
     });
   }, [location, router, sortBy]);
 
-  const handleDelete = useCallback(
+  const handleDeleteReleaseArtifacts = useCallback(
     async (name: string) => {
       addLoadingMessage(t('Removing artifacts\u2026'));
       try {
-        await api.requestPromise(
-          tabDebugIdBundlesActive ? debugIdBundlesEndpoint : sourceMapsEndpoint,
-          {
-            method: 'DELETE',
-            query: tabDebugIdBundlesActive ? {bundleId: name} : {name},
-          }
-        );
-        tabDebugIdBundlesActive ? debugIdBundlesRefetch() : archivesRefetch();
+        await api.requestPromise(sourceMapsEndpoint, {
+          method: 'DELETE',
+          query: {name},
+        });
+        archivesRefetch();
         addSuccessMessage(t('Artifacts removed.'));
       } catch {
         addErrorMessage(t('Unable to remove artifacts. Please try again.'));
       }
     },
-    [
-      api,
-      sourceMapsEndpoint,
-      tabDebugIdBundlesActive,
-      debugIdBundlesRefetch,
-      archivesRefetch,
-      debugIdBundlesEndpoint,
-    ]
+    [api, sourceMapsEndpoint, archivesRefetch]
   );
 
   const currentBundleType = tabDebugIdBundlesActive
@@ -407,64 +402,48 @@ export function ProjectSourceMaps({location, router, project}: Props) {
         onSearch={handleSearch}
         query={query}
       />
-      <Table
-        headers={tableHeaders
-          .filter(header => header.enabledFor.includes(currentBundleType))
-          .map(header => header.component)}
-        emptyMessage={
-          query
-            ? tct('No [tabName] match your search query.', {
-                tabName: tabDebugIdBundlesActive
-                  ? t('artifact bundles')
-                  : t('release bundles'),
-              })
-            : tct('No [tabName] found for this project.', {
-                tabName: tabDebugIdBundlesActive
-                  ? t('artifact bundles')
-                  : t('release bundles'),
-              })
-        }
-        isEmpty={
-          (tabDebugIdBundlesActive ? debugIdBundlesData ?? [] : archivesData ?? [])
-            .length === 0
-        }
-        isLoading={tabDebugIdBundlesActive ? debugIdBundlesLoading : archivesLoading}
-      >
-        {tabDebugIdBundlesActive
-          ? debugIdBundlesData?.map(data => (
-              <SourceMapsTableRow
-                key={data.bundleId}
-                bundleType={SourceMapsBundleType.DEBUG_ID}
-                dateModified={data.dateModified}
-                date={data.date}
-                fileCount={data.fileCount}
-                name={data.bundleId}
-                onDelete={handleDelete}
-                link={`/settings/${organization.slug}/projects/${
-                  project.slug
-                }/source-maps/artifact-bundles/${encodeURIComponent(data.bundleId)}`}
-                idColumnDetails={
-                  <Associations
-                    associations={data.associations}
-                    loading={debugIdBundlesLoading}
-                  />
-                }
-              />
-            ))
-          : archivesData?.map(data => (
-              <SourceMapsTableRow
-                key={data.name}
-                bundleType={SourceMapsBundleType.RELEASE}
-                date={data.date}
-                fileCount={data.fileCount}
-                name={data.name}
-                onDelete={handleDelete}
-                link={`/settings/${organization.slug}/projects/${
-                  project.slug
-                }/source-maps/release-bundles/${encodeURIComponent(data.name)}`}
-              />
-            ))}
-      </Table>
+      {tabDebugIdBundlesActive ? (
+        <DebugIdBundleList
+          isLoading={debugIdBundlesLoading}
+          debugIdBundles={debugIdBundlesData}
+          project={project}
+          onDelete={bundleId =>
+            deleteDebugIdBundle({bundleId, projectSlug: project.slug})
+          }
+          emptyMessage={
+            query
+              ? t('No artifact bundles match your search query.')
+              : t('No artifact bundles found for this project.')
+          }
+        />
+      ) : (
+        <Table
+          headers={tableHeaders
+            .filter(header => header.enabledFor.includes(currentBundleType))
+            .map(header => header.component)}
+          emptyMessage={
+            query
+              ? t('No release bundles match your search query.')
+              : t('No release bundles found for this project.')
+          }
+          isEmpty={(archivesData ?? []).length === 0}
+          isLoading={archivesLoading}
+        >
+          {archivesData?.map(data => (
+            <SourceMapsTableRow
+              key={data.name}
+              bundleType={SourceMapsBundleType.RELEASE}
+              date={data.date}
+              fileCount={data.fileCount}
+              name={data.name}
+              onDelete={handleDeleteReleaseArtifacts}
+              link={`/settings/${organization.slug}/projects/${
+                project.slug
+              }/source-maps/release-bundles/${encodeURIComponent(data.name)}`}
+            />
+          ))}
+        </Table>
+      )}
       <Pagination
         pageLinks={
           tabDebugIdBundlesActive

+ 60 - 12
static/app/views/settings/projectSourceMaps/projectSourceMapsArtifacts.spec.tsx

@@ -3,7 +3,13 @@ import {SourceMapArtifact} from 'sentry-fixture/sourceMapArtifact';
 import {SourceMapsDebugIDBundlesArtifacts} from 'sentry-fixture/sourceMapsDebugIDBundlesArtifacts';
 
 import {initializeOrg} from 'sentry-test/initializeOrg';
-import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
+import {
+  render,
+  renderGlobalModal,
+  screen,
+  userEvent,
+  waitFor,
+} from 'sentry-test/reactTestingLibrary';
 import {textWithMarkupMatcher} from 'sentry-test/utils';
 
 import ConfigStore from 'sentry/stores/configStore';
@@ -52,10 +58,23 @@ function renderDebugIdBundlesMockRequests({
 }) {
   const artifactBundlesFiles = MockApiClient.addMockResponse({
     url: `/projects/${orgSlug}/${projectSlug}/artifact-bundles/7227e105-744e-4066-8c69-3e5e344723fc/files/`,
-    body: empty ? {} : SourceMapsDebugIDBundlesArtifacts(),
+    body: SourceMapsDebugIDBundlesArtifacts(
+      empty
+        ? {
+            fileCount: 0,
+            associations: [],
+            files: [],
+          }
+        : {}
+    ),
   });
 
-  return {artifactBundlesFiles};
+  const artifactBundlesDeletion = MockApiClient.addMockResponse({
+    url: `/projects/${orgSlug}/${projectSlug}/files/artifact-bundles/`,
+    method: 'DELETE',
+  });
+
+  return {artifactBundlesFiles, artifactBundlesDeletion};
 }
 
 describe('ProjectSourceMapsArtifacts', function () {
@@ -171,7 +190,7 @@ describe('ProjectSourceMapsArtifacts', function () {
         user: {...ConfigStore.config.user, isSuperuser: true},
       };
 
-      renderDebugIdBundlesMockRequests({
+      const mockRequests = renderDebugIdBundlesMockRequests({
         orgSlug: organization.slug,
         projectSlug: project.slug,
       });
@@ -190,20 +209,29 @@ describe('ProjectSourceMapsArtifacts', function () {
       );
 
       // Title
-      expect(screen.getByRole('heading')).toHaveTextContent('Artifact Bundle');
-      // Subtitle
-      expect(
-        screen.getByText('7227e105-744e-4066-8c69-3e5e344723fc')
-      ).toBeInTheDocument();
+      expect(screen.getByRole('heading')).toHaveTextContent(
+        '7227e105-744e-4066-8c69-3e5e344723fc'
+      );
 
+      // Details
+      // Artifacts
+      expect(await screen.findByText('Artifacts')).toBeInTheDocument();
+      expect(await screen.findByText('22')).toBeInTheDocument();
       // Release information
+      expect(await screen.findByText('Associated Releases')).toBeInTheDocument();
       expect(
-        await screen.findByText(textWithMarkupMatcher('2 Releases associated'))
+        await screen.findByText(textWithMarkupMatcher('v2.0 (Dist: none)'))
       ).toBeInTheDocument();
-      await userEvent.hover(screen.getByText('2 Releases'));
       expect(
-        await screen.findByText('frontend@2e318148eac9298ec04a662ae32b4b093b027f0a')
+        await screen.findByText(
+          textWithMarkupMatcher(
+            'frontend@2e318148eac9298ec04a662ae32b4b093b027f0a (Dist: android, iOS)'
+          )
+        )
       ).toBeInTheDocument();
+      // Date Uploaded
+      expect(await screen.findByText('Date Uploaded')).toBeInTheDocument();
+      expect(await screen.findByText('Mar 8, 2023 9:53 AM UTC')).toBeInTheDocument();
 
       // Search bar
       expect(screen.getByPlaceholderText('Filter by Path or ID')).toBeInTheDocument();
@@ -221,6 +249,26 @@ describe('ProjectSourceMapsArtifacts', function () {
         'href',
         '/projects/org-slug/project-slug/artifact-bundles/7227e105-744e-4066-8c69-3e5e344723fc/files/ZmlsZXMvXy9fL21haW4uanM=/?download=1'
       );
+
+      renderGlobalModal();
+
+      // Delete item displays a confirmation modal
+      await userEvent.click(screen.getByRole('button', {name: 'Delete Bundle'}));
+      expect(
+        await screen.findByText('Are you sure you want to delete this bundle?')
+      ).toBeInTheDocument();
+      // Close modal
+      await userEvent.click(screen.getByRole('button', {name: 'Confirm'}));
+      await waitFor(() => {
+        expect(mockRequests.artifactBundlesDeletion).toHaveBeenLastCalledWith(
+          '/projects/org-slug/project-slug/files/artifact-bundles/',
+          expect.objectContaining({
+            query: expect.objectContaining({
+              bundleId: '7227e105-744e-4066-8c69-3e5e344723fc',
+            }),
+          })
+        );
+      });
     });
 
     it('renders empty state', async function () {

Some files were not shown because too many files changed in this diff