Browse Source

feat(issue-priority): Add last edited by info and feedback button to dropdown (#66362)

Malachi Willey 1 year ago
parent
commit
b9fcf4a4c7

+ 1 - 0
fixtures/js-stubs/group.ts

@@ -39,6 +39,7 @@ export function GroupFixture(params: Partial<Group> = {}): Group {
     pluginContexts: [],
     pluginContexts: [],
     pluginIssues: [],
     pluginIssues: [],
     priority: PriorityLevel.MEDIUM,
     priority: PriorityLevel.MEDIUM,
+    priorityLockedAt: null,
     project: ProjectFixture({
     project: ProjectFixture({
       platform: 'javascript',
       platform: 'javascript',
     }),
     }),

+ 15 - 0
static/app/components/dropdownMenu/footer.tsx

@@ -0,0 +1,15 @@
+import styled from '@emotion/styled';
+
+import {space} from 'sentry/styles/space';
+
+/**
+ * Provides default styling for custom footer content in a `DropdownMenu`.
+ */
+export const DropdownMenuFooter = styled('div')`
+  border-top: solid 1px ${p => p.theme.innerBorder};
+  padding: ${space(1)} ${space(1.5)};
+  font-size: ${p => p.theme.fontSizeSmall};
+  color: ${p => p.theme.subText};
+  display: flex;
+  align-items: center;
+`;

+ 6 - 0
static/app/components/dropdownMenu/list.tsx

@@ -57,6 +57,10 @@ export interface DropdownMenuListProps
    * Whether the menu should close when an item has been clicked/selected
    * Whether the menu should close when an item has been clicked/selected
    */
    */
   closeOnSelect?: boolean;
   closeOnSelect?: boolean;
+  /**
+   * To be displayed below the menu items
+   */
+  menuFooter?: React.ReactChild;
   /**
   /**
    * Title to display on top of the menu
    * Title to display on top of the menu
    */
    */
@@ -74,6 +78,7 @@ function DropdownMenuList({
   minMenuWidth,
   minMenuWidth,
   size,
   size,
   menuTitle,
   menuTitle,
+  menuFooter,
   overlayState,
   overlayState,
   overlayPositionProps,
   overlayPositionProps,
   ...props
   ...props
@@ -249,6 +254,7 @@ function DropdownMenuList({
             >
             >
               {renderCollection(stateCollection)}
               {renderCollection(stateCollection)}
             </DropdownMenuListWrap>
             </DropdownMenuListWrap>
+            {menuFooter}
           </StyledOverlay>
           </StyledOverlay>
         </DropdownMenuContext.Provider>
         </DropdownMenuContext.Provider>
       </PositionWrapper>
       </PositionWrapper>

+ 54 - 0
static/app/components/group/groupPriority.spec.tsx

@@ -0,0 +1,54 @@
+import {ActivityFeedFixture} from 'sentry-fixture/activityFeed';
+import {UserFixture} from 'sentry-fixture/user';
+
+import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
+import {textWithMarkupMatcher} from 'sentry-test/utils';
+
+import {GroupPriorityDropdown} from 'sentry/components/group/groupPriority';
+import {GroupActivityType, PriorityLevel} from 'sentry/types';
+
+describe('GroupPriority', function () {
+  describe('GroupPriorityDropdown', function () {
+    const defaultProps = {
+      groupId: '1',
+      onChange: jest.fn(),
+      value: PriorityLevel.HIGH,
+    };
+
+    it('skips request when sent lastEditedBy', async function () {
+      render(<GroupPriorityDropdown {...defaultProps} lastEditedBy="system" />);
+
+      await userEvent.click(screen.getByRole('button', {name: 'Modify issue priority'}));
+
+      expect(
+        screen.getByText(textWithMarkupMatcher('Last edited by Sentry'))
+      ).toBeInTheDocument();
+    });
+
+    it('fetches the last priority edit when not passed in', async function () {
+      MockApiClient.addMockResponse({
+        url: '/issues/1/activities/',
+        body: {
+          activity: [
+            ActivityFeedFixture({
+              type: GroupActivityType.SET_PRIORITY,
+              user: UserFixture({name: 'John Doe'}),
+            }),
+            ActivityFeedFixture({
+              type: GroupActivityType.SET_PRIORITY,
+              user: UserFixture({name: 'Other User'}),
+            }),
+          ],
+        },
+      });
+
+      render(<GroupPriorityDropdown {...defaultProps} />);
+
+      await userEvent.click(screen.getByRole('button', {name: 'Modify issue priority'}));
+
+      expect(
+        await screen.findByText(textWithMarkupMatcher('Last edited by John Doe'))
+      ).toBeInTheDocument();
+    });
+  });
+});

+ 8 - 1
static/app/components/group/groupPriority.stories.tsx

@@ -24,6 +24,13 @@ export const Dropdown = storyBook(GroupPriorityDropdown, story => {
   story('Default', () => {
   story('Default', () => {
     const [value, setValue] = useState(PriorityLevel.MEDIUM);
     const [value, setValue] = useState(PriorityLevel.MEDIUM);
 
 
-    return <GroupPriorityDropdown value={value} onChange={setValue} />;
+    return (
+      <GroupPriorityDropdown
+        value={value}
+        onChange={setValue}
+        groupId="1"
+        lastEditedBy="system"
+      />
+    );
   });
   });
 });
 });

+ 127 - 7
static/app/components/group/groupPriority.tsx

@@ -1,18 +1,31 @@
-import {useMemo} from 'react';
+import {useMemo, useRef} from 'react';
 import type {Theme} from '@emotion/react';
 import type {Theme} from '@emotion/react';
 import styled from '@emotion/styled';
 import styled from '@emotion/styled';
 
 
 import {Button} from 'sentry/components/button';
 import {Button} from 'sentry/components/button';
-import {DropdownMenu, type MenuItemProps} from 'sentry/components/dropdownMenu';
+import type {MenuItemProps} from 'sentry/components/dropdownMenu';
+import {DropdownMenu} from 'sentry/components/dropdownMenu';
+import {DropdownMenuFooter} from 'sentry/components/dropdownMenu/footer';
+import useFeedbackWidget from 'sentry/components/feedback/widget/useFeedbackWidget';
+import Placeholder from 'sentry/components/placeholder';
 import Tag from 'sentry/components/tag';
 import Tag from 'sentry/components/tag';
 import {IconChevron} from 'sentry/icons';
 import {IconChevron} from 'sentry/icons';
-import {t} from 'sentry/locale';
+import {t, tct} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
 import {space} from 'sentry/styles/space';
-import {PriorityLevel} from 'sentry/types';
+import {
+  type Activity,
+  type AvatarUser,
+  GroupActivityType,
+  PriorityLevel,
+} from 'sentry/types';
+import {defined} from 'sentry/utils';
+import {useApiQuery} from 'sentry/utils/queryClient';
 
 
 type GroupPriorityDropdownProps = {
 type GroupPriorityDropdownProps = {
+  groupId: string;
   onChange: (value: PriorityLevel) => void;
   onChange: (value: PriorityLevel) => void;
   value: PriorityLevel;
   value: PriorityLevel;
+  lastEditedBy?: 'system' | AvatarUser;
 };
 };
 
 
 type GroupPriorityBadgeProps = {
 type GroupPriorityBadgeProps = {
@@ -40,6 +53,33 @@ function getTagTypeForPriority(priority: string): keyof Theme['tag'] {
   }
   }
 }
 }
 
 
+function useLastEditedBy({
+  groupId,
+  lastEditedBy: incomingLastEditedBy,
+}: Pick<GroupPriorityDropdownProps, 'groupId' | 'lastEditedBy'>) {
+  const {data} = useApiQuery<{activity: Activity[]}>([`/issues/${groupId}/activities/`], {
+    enabled: !defined(incomingLastEditedBy),
+    staleTime: 0,
+  });
+
+  const lastEditedBy = useMemo(() => {
+    if (incomingLastEditedBy) {
+      return incomingLastEditedBy;
+    }
+
+    if (!data) {
+      return null;
+    }
+
+    return (
+      data?.activity?.find(activity => activity.type === GroupActivityType.SET_PRIORITY)
+        ?.user ?? 'system'
+    );
+  }, [data, incomingLastEditedBy]);
+
+  return lastEditedBy;
+}
+
 export function GroupPriorityBadge({priority, children}: GroupPriorityBadgeProps) {
 export function GroupPriorityBadge({priority, children}: GroupPriorityBadgeProps) {
   return (
   return (
     <StyledTag type={getTagTypeForPriority(priority)}>
     <StyledTag type={getTagTypeForPriority(priority)}>
@@ -49,7 +89,49 @@ export function GroupPriorityBadge({priority, children}: GroupPriorityBadgeProps
   );
   );
 }
 }
 
 
-export function GroupPriorityDropdown({value, onChange}: GroupPriorityDropdownProps) {
+function PriorityChangeActor({
+  groupId,
+  lastEditedBy,
+}: Pick<GroupPriorityDropdownProps, 'groupId' | 'lastEditedBy'>) {
+  const resolvedLastEditedBy = useLastEditedBy({groupId, lastEditedBy});
+
+  if (!resolvedLastEditedBy) {
+    return <InlinePlaceholder height="1em" width="60px" />;
+  }
+
+  if (resolvedLastEditedBy === 'system') {
+    return <span>Sentry</span>;
+  }
+
+  return <span>{resolvedLastEditedBy.name}</span>;
+}
+
+function GroupPriorityFeedback() {
+  const buttonRef = useRef<HTMLButtonElement>(null);
+  const feedback = useFeedbackWidget({buttonRef});
+
+  if (!feedback) {
+    return null;
+  }
+
+  return (
+    <StyledButton
+      ref={buttonRef}
+      size="zero"
+      borderless
+      onClick={e => e.stopPropagation()}
+    >
+      {t('Give Feedback')}
+    </StyledButton>
+  );
+}
+
+export function GroupPriorityDropdown({
+  groupId,
+  value,
+  onChange,
+  lastEditedBy,
+}: GroupPriorityDropdownProps) {
   const options: MenuItemProps[] = useMemo(() => {
   const options: MenuItemProps[] = useMemo(() => {
     return PRIORITY_OPTIONS.map(priority => ({
     return PRIORITY_OPTIONS.map(priority => ({
       textValue: PRIORITY_KEY_TO_LABEL[priority],
       textValue: PRIORITY_KEY_TO_LABEL[priority],
@@ -62,8 +144,13 @@ export function GroupPriorityDropdown({value, onChange}: GroupPriorityDropdownPr
   return (
   return (
     <DropdownMenu
     <DropdownMenu
       size="sm"
       size="sm"
-      menuTitle={t('Set Priority To...')}
-      minMenuWidth={160}
+      menuTitle={
+        <MenuTitleContainer>
+          <div>{t('Set Priority')}</div>
+          <GroupPriorityFeedback />
+        </MenuTitleContainer>
+      }
+      minMenuWidth={210}
       trigger={triggerProps => (
       trigger={triggerProps => (
         <DropdownButton
         <DropdownButton
           {...triggerProps}
           {...triggerProps}
@@ -76,6 +163,16 @@ export function GroupPriorityDropdown({value, onChange}: GroupPriorityDropdownPr
         </DropdownButton>
         </DropdownButton>
       )}
       )}
       items={options}
       items={options}
+      menuFooter={
+        <DropdownMenuFooter>
+          <div>
+            {tct('Last edited by [name]', {
+              name: <PriorityChangeActor groupId={groupId} lastEditedBy={lastEditedBy} />,
+            })}
+          </div>
+        </DropdownMenuFooter>
+      }
+      position="bottom-end"
     />
     />
   );
   );
 }
 }
@@ -95,3 +192,26 @@ const StyledTag = styled(Tag)`
     gap: ${space(0.5)};
     gap: ${space(0.5)};
   }
   }
 `;
 `;
+
+const InlinePlaceholder = styled(Placeholder)`
+  display: inline-block;
+  vertical-align: middle;
+`;
+
+const MenuTitleContainer = styled('div')`
+  display: flex;
+  align-items: flex-end;
+  justify-content: space-between;
+`;
+
+const StyledButton = styled(Button)`
+  font-size: ${p => p.theme.fontSizeSmall};
+  color: ${p => p.theme.subText};
+  font-weight: normal;
+  padding: 0;
+  border: none;
+
+  &:hover {
+    color: ${p => p.theme.subText};
+  }
+`;

+ 2 - 2
static/app/styles/text.tsx

@@ -10,8 +10,8 @@ const textStyles = () => css`
   h6,
   h6,
   p,
   p,
   /* Exclude ol/ul elements inside interactive selectors/menus */
   /* Exclude ol/ul elements inside interactive selectors/menus */
-  ul:not([role='listbox'], [role='grid']),
-  ol:not([role='listbox'], [role='grid']),
+  ul:not([role='listbox'], [role='grid'], [role='menu']),
+  ol:not([role='listbox'], [role='grid'], [role='menu']),
   table,
   table,
   dl,
   dl,
   blockquote,
   blockquote,

+ 1 - 0
static/app/types/group.tsx

@@ -784,6 +784,7 @@ export interface BaseGroup {
   pluginContexts: any[]; // TODO(ts)
   pluginContexts: any[]; // TODO(ts)
   pluginIssues: TitledPlugin[];
   pluginIssues: TitledPlugin[];
   priority: PriorityLevel;
   priority: PriorityLevel;
+  priorityLockedAt: string | null;
   project: Project;
   project: Project;
   seenBy: User[];
   seenBy: User[];
   shareId: string;
   shareId: string;

+ 6 - 0
static/app/views/issueDetails/groupPriority.tsx

@@ -44,10 +44,16 @@ function GroupPriority({group}: GroupDetailsPriorityProps) {
     );
     );
   };
   };
 
 
+  // We can assume that when there is not `priorityLockedAt`, there were no
+  // user edits to the priority.
+  const lastEditedBy = !group.priorityLockedAt ? 'system' : undefined;
+
   return (
   return (
     <GroupPriorityDropdown
     <GroupPriorityDropdown
+      groupId={group.id}
       onChange={onChange}
       onChange={onChange}
       value={group.priority ?? PriorityLevel.MEDIUM}
       value={group.priority ?? PriorityLevel.MEDIUM}
+      lastEditedBy={lastEditedBy}
     />
     />
   );
   );
 }
 }