Browse Source

feat(issue-stream): Improve bulk edit animations (#67398)

Uses Framer Motion so we can add exit animations as well as the enter
ones. Just makes the interaction feel a bit better.
Malachi Willey 11 months ago
parent
commit
7e11a5f6fd

+ 40 - 43
static/app/components/actions/archive.tsx

@@ -1,7 +1,6 @@
 import styled from '@emotion/styled';
 
 import {getIgnoreActions} from 'sentry/components/actions/ignore';
-import {IssueActionWrapper} from 'sentry/components/actions/issueActionWrapper';
 import {Button} from 'sentry/components/button';
 import ButtonBar from 'sentry/components/buttonBar';
 import {openConfirmModal} from 'sentry/components/confirm';
@@ -135,49 +134,47 @@ function ArchiveActions({
   });
 
   return (
-    <IssueActionWrapper>
-      <ButtonBar className={className} merged>
-        <ArchiveButton
-          size={size}
-          tooltipProps={{delay: 1000, disabled, isHoverable: true}}
-          title={tct(
-            'We’ll nag you with a notification if the issue gets worse. All archived issues can be found in the Archived tab. [docs:Read the docs]',
-            {
-              docs: (
-                <ExternalLink href="https://docs.sentry.io/product/issues/states-triage/#archive" />
-              ),
-            }
-          )}
-          onClick={() => onArchive(ARCHIVE_UNTIL_ESCALATING)}
-          disabled={disabled}
-        >
-          {t('Archive')}
-        </ArchiveButton>
-        <DropdownMenu
-          minMenuWidth={270}
-          size="sm"
-          trigger={triggerProps => (
-            <DropdownTrigger
-              {...triggerProps}
-              aria-label={t('Archive options')}
-              size={size}
-              icon={<IconChevron direction="down" />}
-              disabled={disabled}
-            />
-          )}
-          menuTitle={
-            <MenuWrapper>
-              {t('Archive')}
-              <StyledExternalLink href="https://docs.sentry.io/product/issues/states-triage/#archive">
-                {t('Read the docs')}
-              </StyledExternalLink>
-            </MenuWrapper>
+    <ButtonBar className={className} merged>
+      <ArchiveButton
+        size={size}
+        tooltipProps={{delay: 1000, disabled, isHoverable: true}}
+        title={tct(
+          'We’ll nag you with a notification if the issue gets worse. All archived issues can be found in the Archived tab. [docs:Read the docs]',
+          {
+            docs: (
+              <ExternalLink href="https://docs.sentry.io/product/issues/states-triage/#archive" />
+            ),
           }
-          items={dropdownItems}
-          isDisabled={disabled}
-        />
-      </ButtonBar>
-    </IssueActionWrapper>
+        )}
+        onClick={() => onArchive(ARCHIVE_UNTIL_ESCALATING)}
+        disabled={disabled}
+      >
+        {t('Archive')}
+      </ArchiveButton>
+      <DropdownMenu
+        minMenuWidth={270}
+        size="sm"
+        trigger={triggerProps => (
+          <DropdownTrigger
+            {...triggerProps}
+            aria-label={t('Archive options')}
+            size={size}
+            icon={<IconChevron direction="down" />}
+            disabled={disabled}
+          />
+        )}
+        menuTitle={
+          <MenuWrapper>
+            {t('Archive')}
+            <StyledExternalLink href="https://docs.sentry.io/product/issues/states-triage/#archive">
+              {t('Read the docs')}
+            </StyledExternalLink>
+          </MenuWrapper>
+        }
+        items={dropdownItems}
+        isDisabled={disabled}
+      />
+    </ButtonBar>
   );
 }
 

+ 0 - 31
static/app/components/actions/issueActionWrapper.tsx

@@ -1,31 +0,0 @@
-import {keyframes} from '@emotion/react';
-import styled from '@emotion/styled';
-
-import useOrganization from 'sentry/utils/useOrganization';
-
-type ActionButtonContainerProps = {children: JSX.Element};
-
-export function IssueActionWrapper({children}: ActionButtonContainerProps) {
-  const organization = useOrganization();
-
-  if (!organization.features.includes('issue-priority-ui')) {
-    return children;
-  }
-
-  return <AnimatedWrapper>{children}</AnimatedWrapper>;
-}
-
-const reveal = keyframes`
-  from {
-    opacity: 0;
-    transform: translateY(10px);
-  }
-  to {
-    opacity: 1;
-    transform: translateY(0);
-  }
-`;
-
-const AnimatedWrapper = styled('div')`
-  animation: ${reveal} 200ms ease-out;
-`;

+ 41 - 48
static/app/views/issueList/actions/actionSet.tsx

@@ -2,7 +2,6 @@ import {Fragment} from 'react';
 
 import ActionLink from 'sentry/components/actions/actionLink';
 import ArchiveActions from 'sentry/components/actions/archive';
-import {IssueActionWrapper} from 'sentry/components/actions/issueActionWrapper';
 import {Button} from 'sentry/components/button';
 import {openConfirmModal} from 'sentry/components/confirm';
 import type {MenuItemProps} from 'sentry/components/dropdownMenu';
@@ -227,59 +226,53 @@ function ActionSet({
         disabled={ignoreDisabled}
       />
       {hasIssuePriority && (
-        <IssueActionWrapper>
-          <DropdownMenu
-            triggerLabel={t('Set Priority')}
-            size="xs"
-            items={makeGroupPriorityDropdownOptions({
-              onChange: priority => {
-                openConfirmModal({
-                  bypass: !onShouldConfirm(ConfirmAction.SET_PRIORITY),
-                  onConfirm: () => onUpdate({priority}),
-                  message: confirm({
-                    action: ConfirmAction.SET_PRIORITY,
-                    append: ` to ${priority}`,
-                    canBeUndone: true,
-                  }),
-                  confirmText: label('reprioritize'),
-                });
-              },
-            })}
-          />
-        </IssueActionWrapper>
+        <DropdownMenu
+          triggerLabel={t('Set Priority')}
+          size="xs"
+          items={makeGroupPriorityDropdownOptions({
+            onChange: priority => {
+              openConfirmModal({
+                bypass: !onShouldConfirm(ConfirmAction.SET_PRIORITY),
+                onConfirm: () => onUpdate({priority}),
+                message: confirm({
+                  action: ConfirmAction.SET_PRIORITY,
+                  append: ` to ${priority}`,
+                  canBeUndone: true,
+                }),
+                confirmText: label('reprioritize'),
+              });
+            },
+          })}
+        />
       )}
       {!nestMergeAndReview && (
         <ReviewAction disabled={!canMarkReviewed} onUpdate={onUpdate} />
       )}
       {!nestMergeAndReview && (
-        <IssueActionWrapper>
-          <ActionLink
-            aria-label={t('Merge Selected Issues')}
-            type="button"
-            disabled={mergeDisabled}
-            onAction={onMerge}
-            shouldConfirm={onShouldConfirm(ConfirmAction.MERGE)}
-            message={confirm({action: ConfirmAction.MERGE, canBeUndone: false})}
-            confirmLabel={label('merge')}
-            title={makeMergeTooltip()}
-          >
-            {t('Merge')}
-          </ActionLink>
-        </IssueActionWrapper>
+        <ActionLink
+          aria-label={t('Merge Selected Issues')}
+          type="button"
+          disabled={mergeDisabled}
+          onAction={onMerge}
+          shouldConfirm={onShouldConfirm(ConfirmAction.MERGE)}
+          message={confirm({action: ConfirmAction.MERGE, canBeUndone: false})}
+          confirmLabel={label('merge')}
+          title={makeMergeTooltip()}
+        >
+          {t('Merge')}
+        </ActionLink>
       )}
-      <IssueActionWrapper>
-        <DropdownMenu
-          size="sm"
-          items={menuItems}
-          triggerProps={{
-            'aria-label': t('More issue actions'),
-            icon: <IconEllipsis />,
-            showChevron: false,
-            size: 'xs',
-          }}
-          isDisabled={!anySelected}
-        />
-      </IssueActionWrapper>
+      <DropdownMenu
+        size="sm"
+        items={menuItems}
+        triggerProps={{
+          'aria-label': t('More issue actions'),
+          icon: <IconEllipsis />,
+          showChevron: false,
+          size: 'xs',
+        }}
+        isDisabled={!anySelected}
+      />
     </Fragment>
   );
 }

+ 6 - 22
static/app/views/issueList/actions/headers.tsx

@@ -1,5 +1,4 @@
 import {Fragment} from 'react';
-import {keyframes} from '@emotion/react';
 import styled from '@emotion/styled';
 
 import ToolbarHeader from 'sentry/components/toolbarHeader';
@@ -72,21 +71,10 @@ function Headers({
 
 export default Headers;
 
-const fadeIn = keyframes`
-  from {
-    opacity: 0;
-  }
-
-  to {
-    opacity: 1;
-  }
-`;
-
 const GraphHeaderWrapper = styled('div')<{isSavedSearchesOpen?: boolean}>`
   width: 160px;
   margin-left: ${space(2)};
   margin-right: ${space(2)};
-  animation: 200ms ${fadeIn} ease-out;
 
   /* prettier-ignore */
   @media (max-width: ${p =>
@@ -115,11 +103,7 @@ const GraphToggle = styled('a')<{active: boolean}>`
   }
 `;
 
-const FadeInHeader = styled(ToolbarHeader)`
-  animation: 200ms ${fadeIn} ease-out;
-`;
-
-const EventsOrUsersLabel = styled(FadeInHeader)`
+const EventsOrUsersLabel = styled(ToolbarHeader)`
   display: inline-grid;
   align-items: center;
   justify-content: flex-end;
@@ -132,7 +116,7 @@ const EventsOrUsersLabel = styled(FadeInHeader)`
   }
 `;
 
-const PriorityLabel = styled(FadeInHeader)<{isSavedSearchesOpen?: boolean}>`
+const PriorityLabel = styled(ToolbarHeader)<{isSavedSearchesOpen?: boolean}>`
   justify-content: flex-end;
   text-align: right;
   width: 70px;
@@ -145,7 +129,7 @@ const PriorityLabel = styled(FadeInHeader)<{isSavedSearchesOpen?: boolean}>`
   }
 `;
 
-const AssigneeLabel = styled(FadeInHeader)<{isSavedSearchesOpen?: boolean}>`
+const AssigneeLabel = styled(ToolbarHeader)<{isSavedSearchesOpen?: boolean}>`
   justify-content: flex-end;
   text-align: right;
   width: 60px;
@@ -160,7 +144,7 @@ const AssigneeLabel = styled(FadeInHeader)<{isSavedSearchesOpen?: boolean}>`
 `;
 
 // Reprocessing
-const StartedColumn = styled(FadeInHeader)`
+const StartedColumn = styled(ToolbarHeader)`
   margin: 0 ${space(2)};
   ${p => p.theme.overflowEllipsis};
   width: 85px;
@@ -170,7 +154,7 @@ const StartedColumn = styled(FadeInHeader)`
   }
 `;
 
-const EventsReprocessedColumn = styled(FadeInHeader)`
+const EventsReprocessedColumn = styled(ToolbarHeader)`
   margin: 0 ${space(2)};
   ${p => p.theme.overflowEllipsis};
   width: 75px;
@@ -180,7 +164,7 @@ const EventsReprocessedColumn = styled(FadeInHeader)`
   }
 `;
 
-const ProgressColumn = styled(FadeInHeader)`
+const ProgressColumn = styled(ToolbarHeader)`
   margin: 0 ${space(2)};
 
   display: none;

+ 6 - 4
static/app/views/issueList/actions/index.spec.tsx

@@ -86,7 +86,7 @@ describe('IssueListActions', function () {
 
         await userEvent.click(screen.getByTestId('issue-list-select-all-notice-link'));
 
-        await userEvent.click(screen.getByRole('button', {name: 'Resolve'}));
+        await userEvent.click(await screen.findByRole('button', {name: 'Resolve'}));
 
         await screen.findByRole('dialog');
 
@@ -115,7 +115,7 @@ describe('IssueListActions', function () {
 
         await userEvent.click(screen.getByRole('checkbox'));
         await userEvent.click(screen.getByTestId('issue-list-select-all-notice-link'));
-        await userEvent.click(screen.getByRole('button', {name: 'Set Priority'}));
+        await userEvent.click(await screen.findByRole('button', {name: 'Set Priority'}));
         await userEvent.click(screen.getByRole('menuitemradio', {name: 'High'}));
 
         expect(
@@ -167,7 +167,7 @@ describe('IssueListActions', function () {
 
         await userEvent.click(screen.getByTestId('issue-list-select-all-notice-link'));
 
-        await userEvent.click(screen.getByRole('button', {name: 'Resolve'}));
+        await userEvent.click(await screen.findByRole('button', {name: 'Resolve'}));
 
         const modal = screen.getByRole('dialog');
 
@@ -439,7 +439,9 @@ describe('IssueListActions', function () {
 
         await userEvent.click(screen.getByTestId('issue-list-select-all-notice-link'));
 
-        await userEvent.click(screen.getByRole('button', {name: 'More issue actions'}));
+        await userEvent.click(
+          await screen.findByRole('button', {name: 'More issue actions'})
+        );
         await userEvent.click(screen.getByRole('menuitemradio', {name: 'Delete'}));
 
         const modal = screen.getByRole('dialog');

+ 57 - 34
static/app/views/issueList/actions/index.tsx

@@ -1,5 +1,6 @@
 import {Fragment, useEffect, useState} from 'react';
 import styled from '@emotion/styled';
+import {AnimatePresence, type AnimationProps, motion} from 'framer-motion';
 
 import {bulkDelete, bulkUpdate, mergeGroups} from 'sentry/actionCreators/group';
 import {
@@ -48,6 +49,13 @@ type IssueListActionsProps = {
   onActionTaken?: (itemIds: string[], data: IssueUpdateData) => void;
 };
 
+const animationProps: AnimationProps = {
+  initial: {translateY: 8, opacity: 0},
+  animate: {translateY: 0, opacity: 1},
+  exit: {translateY: -8, opacity: 0},
+  transition: {duration: 0.1},
+};
+
 function ActionsBarPriority({
   anySelected,
   narrowViewport,
@@ -91,10 +99,6 @@ function ActionsBarPriority({
 }) {
   const shouldDisplayActions = anySelected && !narrowViewport;
 
-  const sortDropdown = (
-    <IssueListSortOptions sort={sort} query={query} onSelect={onSortChange} />
-  );
-
   return (
     <ActionsBarContainer>
       {!narrowViewport && (
@@ -107,38 +111,52 @@ function ActionsBarPriority({
         </ActionsCheckbox>
       )}
       {!displayReprocessingActions && (
-        <HeaderButtonsWrapper>
+        <AnimatePresence initial={false} exitBeforeEnter>
           {shouldDisplayActions && (
-            <ActionSet
-              queryCount={queryCount}
-              query={query}
-              issues={selectedIdsSet}
-              allInQuerySelected={allInQuerySelected}
-              anySelected={anySelected}
-              multiSelected={multiSelected}
-              selectedProjectSlug={selectedProjectSlug}
-              onShouldConfirm={action =>
-                shouldConfirm(action, {pageSelected, selectedIdsSet})
-              }
-              onDelete={handleDelete}
-              onMerge={handleMerge}
-              onUpdate={handleUpdate}
-            />
+            <HeaderButtonsWrapper key="actions" {...animationProps}>
+              <ActionSet
+                queryCount={queryCount}
+                query={query}
+                issues={selectedIdsSet}
+                allInQuerySelected={allInQuerySelected}
+                anySelected={anySelected}
+                multiSelected={multiSelected}
+                selectedProjectSlug={selectedProjectSlug}
+                onShouldConfirm={action =>
+                  shouldConfirm(action, {pageSelected, selectedIdsSet})
+                }
+                onDelete={handleDelete}
+                onMerge={handleMerge}
+                onUpdate={handleUpdate}
+              />
+            </HeaderButtonsWrapper>
           )}
-          {!anySelected ? sortDropdown : null}
-        </HeaderButtonsWrapper>
-      )}
-      {!anySelected ? (
-        <Headers
-          onSelectStatsPeriod={onSelectStatsPeriod}
-          selection={selection}
-          statsPeriod={statsPeriod}
-          isReprocessingQuery={displayReprocessingActions}
-          isSavedSearchesOpen={isSavedSearchesOpen}
-        />
-      ) : (
-        <SortDropdownMargin>{sortDropdown}</SortDropdownMargin>
+          {!anySelected && (
+            <HeaderButtonsWrapper key="sort" {...animationProps}>
+              <IssueListSortOptions sort={sort} query={query} onSelect={onSortChange} />
+            </HeaderButtonsWrapper>
+          )}
+        </AnimatePresence>
       )}
+      <AnimatePresence initial={false} exitBeforeEnter>
+        {!anySelected ? (
+          <AnimatedHeaderItemsContainer key="headers" {...animationProps}>
+            <Headers
+              onSelectStatsPeriod={onSelectStatsPeriod}
+              selection={selection}
+              statsPeriod={statsPeriod}
+              isReprocessingQuery={displayReprocessingActions}
+              isSavedSearchesOpen={isSavedSearchesOpen}
+            />
+          </AnimatedHeaderItemsContainer>
+        ) : (
+          <motion.div key="sort" {...animationProps}>
+            <SortDropdownMargin>
+              <IssueListSortOptions sort={sort} query={query} onSelect={onSortChange} />
+            </SortDropdownMargin>
+          </motion.div>
+        )}
+      </AnimatePresence>
     </ActionsBarContainer>
   );
 }
@@ -528,7 +546,7 @@ const ActionsCheckbox = styled('div')<{isReprocessingQuery: boolean}>`
   ${p => p.isReprocessingQuery && 'flex: 1'};
 `;
 
-const HeaderButtonsWrapper = styled('div')`
+const HeaderButtonsWrapper = styled(motion.div)`
   @media (min-width: ${p => p.theme.breakpoints.large}) {
     width: 50%;
   }
@@ -560,6 +578,11 @@ const SortDropdownMargin = styled('div')`
   margin-right: ${space(1)};
 `;
 
+const AnimatedHeaderItemsContainer = styled(motion.div)`
+  display: flex;
+  align-items: center;
+`;
+
 export {IssueListActions};
 
 export default IssueListActions;

+ 13 - 16
static/app/views/issueList/actions/resolveActions.tsx

@@ -1,4 +1,3 @@
-import {IssueActionWrapper} from 'sentry/components/actions/issueActionWrapper';
 import ResolveActions from 'sentry/components/actions/resolve';
 import useProjects from 'sentry/utils/useProjects';
 
@@ -44,21 +43,19 @@ function ResolveActionsContainer({
   );
 
   return (
-    <IssueActionWrapper>
-      <ResolveActions
-        hasRelease={hasRelease}
-        multipleProjectsSelected={!selectedProjectSlug}
-        latestRelease={latestRelease}
-        projectSlug={project?.slug}
-        onUpdate={onUpdate}
-        shouldConfirm={onShouldConfirm(ConfirmAction.RESOLVE)}
-        confirmMessage={confirm({action: ConfirmAction.RESOLVE, canBeUndone: true})}
-        confirmLabel={label('resolve')}
-        disabled={resolveDisabled}
-        disableDropdown={resolveDropdownDisabled}
-        projectFetchError={Boolean(fetchError)}
-      />
-    </IssueActionWrapper>
+    <ResolveActions
+      hasRelease={hasRelease}
+      multipleProjectsSelected={!selectedProjectSlug}
+      latestRelease={latestRelease}
+      projectSlug={project?.slug}
+      onUpdate={onUpdate}
+      shouldConfirm={onShouldConfirm(ConfirmAction.RESOLVE)}
+      confirmMessage={confirm({action: ConfirmAction.RESOLVE, canBeUndone: true})}
+      confirmLabel={label('resolve')}
+      disabled={resolveDisabled}
+      disableDropdown={resolveDropdownDisabled}
+      projectFetchError={Boolean(fetchError)}
+    />
   );
 }
 

+ 10 - 13
static/app/views/issueList/actions/reviewAction.tsx

@@ -1,5 +1,4 @@
 import ActionLink from 'sentry/components/actions/actionLink';
-import {IssueActionWrapper} from 'sentry/components/actions/issueActionWrapper';
 import type {TooltipProps} from 'sentry/components/tooltip';
 import {IconIssues} from 'sentry/icons';
 import {t} from 'sentry/locale';
@@ -14,18 +13,16 @@ type Props = {
 
 function ReviewAction({disabled, onUpdate, tooltipProps, tooltip}: Props) {
   return (
-    <IssueActionWrapper>
-      <ActionLink
-        type="button"
-        disabled={disabled}
-        onAction={() => onUpdate({inbox: false})}
-        icon={<IconIssues size="xs" />}
-        title={tooltip}
-        tooltipProps={tooltipProps}
-      >
-        {t('Mark Reviewed')}
-      </ActionLink>
-    </IssueActionWrapper>
+    <ActionLink
+      type="button"
+      disabled={disabled}
+      onAction={() => onUpdate({inbox: false})}
+      icon={<IconIssues size="xs" />}
+      title={tooltip}
+      tooltipProps={tooltipProps}
+    >
+      {t('Mark Reviewed')}
+    </ActionLink>
   );
 }
 

+ 5 - 5
static/app/views/issueList/overview.actions.spec.tsx

@@ -163,7 +163,7 @@ describe('IssueListOverview (actions)', function () {
         headers: {Link: DEFAULT_LINKS_HEADER},
       });
 
-      await userEvent.click(screen.getByRole('button', {name: 'Resolve'}));
+      await userEvent.click(await screen.findByRole('button', {name: 'Resolve'}));
 
       expect(updateIssueMock).toHaveBeenCalledWith(
         '/organizations/org-slug/issues/',
@@ -207,7 +207,7 @@ describe('IssueListOverview (actions)', function () {
         headers: {Link: DEFAULT_LINKS_HEADER},
       });
 
-      await userEvent.click(screen.getByRole('button', {name: 'Resolve'}));
+      await userEvent.click(await screen.findByRole('button', {name: 'Resolve'}));
 
       expect(updateIssueMock).toHaveBeenCalledWith(
         '/organizations/org-slug/issues/',
@@ -302,7 +302,7 @@ describe('IssueListOverview (actions)', function () {
         headers: {Link: DEFAULT_LINKS_HEADER},
       });
 
-      await userEvent.click(screen.getByRole('button', {name: 'Mark Reviewed'}));
+      await userEvent.click(await screen.findByRole('button', {name: 'Mark Reviewed'}));
 
       expect(updateIssueMock).toHaveBeenCalledWith(
         '/organizations/org-slug/issues/',
@@ -360,7 +360,7 @@ describe('IssueListOverview (actions)', function () {
         headers: {Link: DEFAULT_LINKS_HEADER},
       });
 
-      await userEvent.click(screen.getByRole('button', {name: /set priority/i}));
+      await userEvent.click(await screen.findByRole('button', {name: /set priority/i}));
       await userEvent.click(screen.getByRole('menuitemradio', {name: /low/i}));
 
       expect(updateIssueMock).toHaveBeenCalledWith(
@@ -442,7 +442,7 @@ describe('IssueListOverview (actions)', function () {
 
       expect(screen.getByText('Medium priority issue')).toBeInTheDocument();
 
-      await userEvent.click(screen.getByRole('button', {name: /set priority/i}));
+      await userEvent.click(await screen.findByRole('button', {name: /set priority/i}));
       await userEvent.click(screen.getByRole('menuitemradio', {name: /low/i}));
 
       expect(updateIssueMock).toHaveBeenCalledWith(