Browse Source

ref(feedback): Refactor Feedback Bulk-Edit confirm dialog, and implement `bypass` (#61325)

Refactored the bulk edit stuff to:
- Shove all the (3) callbacks into a hook. So the confirm dialog stuff,
and success/error toasts are all in there
- Replace our one-off Confirm dialog with `sentry/components/confirm`
instead
- Implement `bypass` so if one feedback is selected, there's no confirm
dialog. If you've got 2 or all, then we confirm.
- After doing a bulk action, all checked items become unchecked.

Fixes https://github.com/getsentry/sentry/issues/60802
Ryan Albrecht 1 year ago
parent
commit
aabe349f5b

+ 76 - 0
static/app/components/feedback/list/feedbackListBulkSelection.tsx

@@ -0,0 +1,76 @@
+import Button from 'sentry/components/actions/button';
+import {DropdownMenu} from 'sentry/components/dropdownMenu';
+import ErrorBoundary from 'sentry/components/errorBoundary';
+import decodeMailbox from 'sentry/components/feedback/decodeMailbox';
+import useBulkEditFeedbacks from 'sentry/components/feedback/list/useBulkEditFeedbacks';
+import type useListItemCheckboxState from 'sentry/components/feedback/list/useListItemCheckboxState';
+import {Flex} from 'sentry/components/profiling/flex';
+import {IconEllipsis} from 'sentry/icons/iconEllipsis';
+import {t, tct} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import {GroupStatus} from 'sentry/types';
+
+interface Props
+  extends Pick<
+    ReturnType<typeof useListItemCheckboxState>,
+    'countSelected' | 'deselectAll' | 'selectedIds'
+  > {
+  mailbox: ReturnType<typeof decodeMailbox>;
+}
+
+export default function FeedbackListBulkSelection({
+  mailbox,
+  countSelected,
+  selectedIds,
+  deselectAll,
+}: Props) {
+  const {onToggleResovled, onMarkAsRead, onMarkUnread} = useBulkEditFeedbacks({
+    selectedIds,
+    deselectAll,
+  });
+
+  const newMailbox =
+    mailbox === 'resolved' ? GroupStatus.UNRESOLVED : GroupStatus.RESOLVED;
+
+  return (
+    <Flex gap={space(1)} align="center" justify="space-between" flex="1 0 auto">
+      <span>
+        <strong>
+          {tct('[countSelected] Selected', {
+            countSelected,
+          })}
+        </strong>
+      </span>
+      <Flex gap={space(1)} justify="flex-end">
+        <ErrorBoundary mini>
+          <Button onClick={() => onToggleResovled(newMailbox)}>
+            {mailbox === 'resolved' ? t('Unresolve') : t('Resolve')}
+          </Button>
+        </ErrorBoundary>
+        <ErrorBoundary mini>
+          <DropdownMenu
+            position="bottom-end"
+            triggerProps={{
+              'aria-label': t('Read Menu'),
+              icon: <IconEllipsis size="xs" />,
+              showChevron: false,
+              size: 'xs',
+            }}
+            items={[
+              {
+                key: 'mark read',
+                label: t('Mark Read'),
+                onAction: onMarkAsRead,
+              },
+              {
+                key: 'mark unread',
+                label: t('Mark Unread'),
+                onAction: onMarkUnread,
+              },
+            ]}
+          />
+        </ErrorBoundary>
+      </Flex>
+    </Flex>
+  );
+}

+ 2 - 170
static/app/components/feedback/list/feedbackListHeader.tsx

@@ -1,63 +1,15 @@
-import {Fragment, ReactNode} from 'react';
 import styled from '@emotion/styled';
 
-import {
-  addErrorMessage,
-  addLoadingMessage,
-  addSuccessMessage,
-} from 'sentry/actionCreators/indicator';
-import {ModalRenderProps, openModal} from 'sentry/actionCreators/modal';
-import Button from 'sentry/components/actions/button';
-import ButtonBar from 'sentry/components/buttonBar';
 import Checkbox from 'sentry/components/checkbox';
-import {DropdownMenu} from 'sentry/components/dropdownMenu';
-import ErrorBoundary from 'sentry/components/errorBoundary';
 import decodeMailbox from 'sentry/components/feedback/decodeMailbox';
+import FeedbackListBulkSelection from 'sentry/components/feedback/list/feedbackListBulkSelection';
 import MailboxPicker from 'sentry/components/feedback/list/mailboxPicker';
 import type useListItemCheckboxState from 'sentry/components/feedback/list/useListItemCheckboxState';
-import useMutateFeedback from 'sentry/components/feedback/useMutateFeedback';
 import PanelItem from 'sentry/components/panels/panelItem';
-import {Flex} from 'sentry/components/profiling/flex';
-import {IconEllipsis} from 'sentry/icons/iconEllipsis';
-import {t, tct} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
-import {GroupStatus} from 'sentry/types';
 import useLocationQuery from 'sentry/utils/url/useLocationQuery';
-import useOrganization from 'sentry/utils/useOrganization';
 import useUrlParams from 'sentry/utils/useUrlParams';
 
-function openConfirmModal({
-  onConfirm,
-  body,
-  footerConfirm,
-}: {
-  body: ReactNode;
-  footerConfirm: ReactNode;
-  onConfirm: () => void | Promise<void>;
-}) {
-  openModal(({Body, Footer, closeModal}: ModalRenderProps) => (
-    <Fragment>
-      <Body>{body}</Body>
-      <Footer>
-        <Flex gap={space(1)}>
-          <ButtonBar gap={1}>
-            <Button onClick={closeModal}>{t('Cancel')}</Button>
-            <Button
-              priority="primary"
-              onClick={() => {
-                closeModal();
-                onConfirm();
-              }}
-            >
-              {footerConfirm}
-            </Button>
-          </ButtonBar>
-        </Flex>
-      </Footer>
-    </Fragment>
-  ));
-}
-
 interface Props
   extends Pick<
     ReturnType<typeof useListItemCheckboxState>,
@@ -69,11 +21,6 @@ interface Props
     | 'selectedIds'
   > {}
 
-const statusToText: Record<string, string> = {
-  resolved: 'Resolve',
-  unresolved: 'Unresolve',
-};
-
 export default function FeedbackListHeader({
   countSelected,
   deselectAll,
@@ -102,7 +49,7 @@ export default function FeedbackListHeader({
         }}
       />
       {isAnySelected ? (
-        <HasSelection
+        <FeedbackListBulkSelection
           mailbox={mailbox}
           countSelected={countSelected}
           selectedIds={selectedIds}
@@ -115,121 +62,6 @@ export default function FeedbackListHeader({
   );
 }
 
-interface HasSelectionProps
-  extends Pick<
-    ReturnType<typeof useListItemCheckboxState>,
-    'countSelected' | 'selectedIds' | 'deselectAll'
-  > {
-  mailbox: ReturnType<typeof decodeMailbox>;
-}
-
-function HasSelection({
-  mailbox,
-  countSelected,
-  selectedIds,
-  deselectAll,
-}: HasSelectionProps) {
-  const organization = useOrganization();
-  const {markAsRead, resolve} = useMutateFeedback({
-    feedbackIds: selectedIds,
-    organization,
-  });
-
-  const mutationOptionsResolve = {
-    onError: () => {
-      addErrorMessage(t('An error occurred while updating the feedbacks.'));
-    },
-    onSuccess: () => {
-      addSuccessMessage(t('Updated feedbacks'));
-      deselectAll();
-    },
-  };
-
-  const mutationOptionsRead = {
-    onError: () => {
-      addErrorMessage(t('An error occurred while updating the feedbacks.'));
-    },
-    onSuccess: () => {
-      addSuccessMessage(t('Updated feedbacks'));
-    },
-  };
-
-  return (
-    <Flex gap={space(1)} align="center" justify="space-between" style={{flexGrow: 1}}>
-      <span>
-        <strong>
-          {tct('[countSelected] Selected', {
-            countSelected,
-          })}
-        </strong>
-      </span>
-      <Flex gap={space(1)} justify="flex-end">
-        <ErrorBoundary mini>
-          <Button
-            onClick={() => {
-              const newStatus =
-                mailbox === 'resolved' ? GroupStatus.UNRESOLVED : GroupStatus.RESOLVED;
-              openConfirmModal({
-                onConfirm: () => {
-                  addLoadingMessage(t('Updating feedbacks...'));
-                  resolve(newStatus, mutationOptionsResolve);
-                },
-                body: tct('Are you sure you want to [status] these feedbacks?', {
-                  status: statusToText[newStatus].toLowerCase(),
-                }),
-                footerConfirm: statusToText[newStatus],
-              });
-            }}
-          >
-            {mailbox === 'resolved' ? t('Unresolve') : t('Resolve')}
-          </Button>
-        </ErrorBoundary>
-        <ErrorBoundary mini>
-          <DropdownMenu
-            position="bottom-end"
-            triggerProps={{
-              'aria-label': t('Read Menu'),
-              icon: <IconEllipsis size="xs" />,
-              showChevron: false,
-              size: 'xs',
-            }}
-            items={[
-              {
-                key: 'mark read',
-                label: t('Mark Read'),
-                onAction: () => {
-                  openConfirmModal({
-                    onConfirm: () => {
-                      addLoadingMessage(t('Updating feedbacks...'));
-                      markAsRead(true, mutationOptionsRead);
-                    },
-                    body: t('Are you sure you want to mark these feedbacks as read?'),
-                    footerConfirm: 'Mark read',
-                  });
-                },
-              },
-              {
-                key: 'mark unread',
-                label: t('Mark Unread'),
-                onAction: () => {
-                  openConfirmModal({
-                    onConfirm: () => {
-                      addLoadingMessage(t('Updating feedbacks...'));
-                      markAsRead(false, mutationOptionsRead);
-                    },
-                    body: t('Are you sure you want to mark these feedbacks as unread?'),
-                    footerConfirm: 'Mark unread',
-                  });
-                },
-              },
-            ]}
-          />
-        </ErrorBoundary>
-      </Flex>
-    </Flex>
-  );
-}
-
 const HeaderPanelItem = styled(PanelItem)`
   display: flex;
   padding: ${space(1)} ${space(2)} ${space(1)} ${space(2)};

+ 1 - 1
static/app/components/feedback/list/mailboxPicker.tsx

@@ -12,7 +12,7 @@ interface Props {
 
 export default function MailboxPicker({onChange, value}: Props) {
   return (
-    <Flex justify="flex-end" style={{flexGrow: 1}}>
+    <Flex justify="flex-end" flex="1 0 auto">
       <SegmentedControl
         size="xs"
         aria-label={t('Filter feedbacks')}

+ 107 - 0
static/app/components/feedback/list/useBulkEditFeedbacks.tsx

@@ -0,0 +1,107 @@
+import {useCallback} from 'react';
+
+import {
+  addErrorMessage,
+  addLoadingMessage,
+  addSuccessMessage,
+} from 'sentry/actionCreators/indicator';
+import {openConfirmModal} from 'sentry/components/confirm';
+import useListItemCheckboxState from 'sentry/components/feedback/list/useListItemCheckboxState';
+import useMutateFeedback from 'sentry/components/feedback/useMutateFeedback';
+import {t, tct} from 'sentry/locale';
+import {GroupStatus} from 'sentry/types';
+import useOrganization from 'sentry/utils/useOrganization';
+
+const statusToText: Record<string, string> = {
+  resolved: 'Resolve',
+  unresolved: 'Unresolve',
+};
+
+interface Props
+  extends Pick<
+    ReturnType<typeof useListItemCheckboxState>,
+    'deselectAll' | 'selectedIds'
+  > {}
+
+export default function useBulkEditFeedbacks({deselectAll, selectedIds}: Props) {
+  const organization = useOrganization();
+  const {markAsRead, resolve} = useMutateFeedback({
+    feedbackIds: selectedIds,
+    organization,
+  });
+
+  const onToggleResovled = useCallback(
+    (newMailbox: GroupStatus) => {
+      openConfirmModal({
+        bypass: Array.isArray(selectedIds) && selectedIds.length === 1,
+        onConfirm: () => {
+          addLoadingMessage(t('Updating feedbacks...'));
+          resolve(newMailbox, {
+            onError: () => {
+              addErrorMessage(t('An error occurred while updating the feedbacks.'));
+            },
+            onSuccess: () => {
+              addSuccessMessage(t('Updated feedbacks'));
+              deselectAll();
+            },
+          });
+        },
+        message: tct('Are you sure you want to [status] these feedbacks?', {
+          status: statusToText[newMailbox].toLowerCase(),
+        }),
+        confirmText: statusToText[newMailbox],
+      });
+    },
+    [deselectAll, resolve, selectedIds]
+  );
+
+  const onMarkAsRead = useCallback(
+    () =>
+      openConfirmModal({
+        bypass: Array.isArray(selectedIds) && selectedIds.length === 1,
+        onConfirm: () => {
+          addLoadingMessage(t('Updating feedbacks...'));
+          markAsRead(true, {
+            onError: () => {
+              addErrorMessage(t('An error occurred while updating the feedbacks.'));
+            },
+            onSuccess: () => {
+              addSuccessMessage(t('Updated feedbacks'));
+              deselectAll();
+            },
+          });
+        },
+        message: t('Are you sure you want to mark these feedbacks as read?'),
+        confirmText: 'Mark read',
+      }),
+    [deselectAll, markAsRead, selectedIds]
+  );
+
+  const onMarkUnread = useCallback(
+    () =>
+      openConfirmModal({
+        bypass: Array.isArray(selectedIds) && selectedIds.length === 1,
+        onConfirm: () => {
+          addLoadingMessage(t('Updating feedbacks...'));
+          markAsRead(false, {
+            onError: () => {
+              addErrorMessage(t('An error occurred while updating the feedbacks.'));
+            },
+            onSuccess: () => {
+              addSuccessMessage(t('Updated feedbacks'));
+              deselectAll();
+            },
+          });
+        },
+        message: t('Are you sure you want to mark these feedbacks as unread?'),
+        confirmText: 'Mark unread',
+      }),
+    [deselectAll, markAsRead, selectedIds]
+  );
+
+  return {
+    onToggleResovled,
+    onMarkAsRead,
+    onMarkUnread,
+  };
+}

+ 2 - 0
static/app/components/profiling/flex.tsx

@@ -7,6 +7,7 @@ import toPixels from 'sentry/utils/number/toPixels';
 interface FlexProps {
   align?: CSSProperties['alignItems'];
   column?: boolean;
+  flex?: CSSProperties['flex'];
   gap?: number | CssSize;
   h?: number | CssSize;
   justify?: CSSProperties['justifyContent'];
@@ -56,6 +57,7 @@ const FlexContainer = styled('div')<FlexProps>`
   align-items: ${p => p.align};
   gap: ${p => toPixels(p.gap)};
   flex-wrap: ${p => p.wrap};
+  flex: ${p => p.flex ?? 'initial'};
 `;
 
 interface FlexItemProps {