Browse Source

feat(flags): add general sort category and granular sort (#78073)

default state is "evaluation order" --> "newest first":

<img width="1045" alt="SCR-20240924-msnr"
src="https://github.com/user-attachments/assets/e6bddce5-e325-4e8a-a2f1-a6c0a0e68221">

main component:


https://github.com/user-attachments/assets/e5b80acb-98fb-4b5e-8d2f-862d8330b9b6

drawer:


https://github.com/user-attachments/assets/b0ffff98-2537-4787-a63f-08ae7e0b84c6
Michelle Zhang 5 months ago
parent
commit
280f5360a8

+ 32 - 7
static/app/components/events/featureFlags/eventFeatureFlagList.spec.tsx

@@ -73,14 +73,33 @@ describe('EventFeatureFlagList', function () {
     expect(drawerControl).toHaveFocus();
   });
 
-  it('renders a sorting dropdown with Newest First as the default', async function () {
+  it('renders a flag granular sort dropdown with Newest as the default', async function () {
     render(<EventFeatureFlagList {...MOCK_DATA_SECTION_PROPS} />);
 
-    const control = screen.getByRole('button', {name: 'Newest First'});
+    const control = screen.getByRole('button', {name: 'Newest'});
     expect(control).toBeInTheDocument();
     await userEvent.click(control);
+    expect(screen.getByRole('option', {name: 'Oldest'})).toBeInTheDocument();
+  });
+
+  it('renders a sort group dropdown with Evaluation Order as the default', async function () {
+    render(<EventFeatureFlagList {...MOCK_DATA_SECTION_PROPS} />);
+
+    const control = screen.getByRole('button', {name: 'Evaluation Order'});
+    expect(control).toBeInTheDocument();
+    await userEvent.click(control);
+    expect(screen.getByRole('option', {name: 'Evaluation Order'})).toBeInTheDocument();
     expect(screen.getByRole('option', {name: 'Alphabetical'})).toBeInTheDocument();
-    expect(screen.getByRole('option', {name: 'Oldest First'})).toBeInTheDocument();
+  });
+
+  it('renders a sort group dropdown which affects the granular sort dropdown', async function () {
+    render(<EventFeatureFlagList {...MOCK_DATA_SECTION_PROPS} />);
+
+    const control = screen.getByRole('button', {name: 'Evaluation Order'});
+    expect(control).toBeInTheDocument();
+    await userEvent.click(control);
+    await userEvent.click(screen.getByRole('option', {name: 'Alphabetical'}));
+    expect(screen.getByRole('button', {name: 'A-Z'})).toBeInTheDocument();
   });
 
   it('allows sort dropdown to affect displayed flags', async function () {
@@ -97,10 +116,10 @@ describe('EventFeatureFlagList', function () {
 
     // the sort should be reversed
     const sortControl = screen.getByRole('button', {
-      name: 'Newest First',
+      name: 'Newest',
     });
     await userEvent.click(sortControl);
-    await userEvent.click(screen.getByRole('option', {name: 'Oldest First'}));
+    await userEvent.click(screen.getByRole('option', {name: 'Oldest'}));
 
     expect(
       screen
@@ -108,13 +127,19 @@ describe('EventFeatureFlagList', function () {
         .compareDocumentPosition(screen.getByText(enableReplay.flag))
     ).toBe(document.DOCUMENT_POSITION_PRECEDING);
 
-    await userEvent.click(sortControl);
+    const sortGroupControl = screen.getByRole('button', {
+      name: 'Evaluation Order',
+    });
+    await userEvent.click(sortGroupControl);
     await userEvent.click(screen.getByRole('option', {name: 'Alphabetical'}));
+    await userEvent.click(sortControl);
+    await userEvent.click(screen.getByRole('option', {name: 'Z-A'}));
 
+    // webVitalsFlag comes after enableReplay alphabetically
     expect(
       screen
         .getByText(webVitalsFlag.flag)
         .compareDocumentPosition(screen.getByText(enableReplay.flag))
-    ).toBe(document.DOCUMENT_POSITION_FOLLOWING);
+    ).toBe(document.DOCUMENT_POSITION_PRECEDING);
   });
 });

+ 35 - 24
static/app/components/events/featureFlags/eventFeatureFlagList.tsx

@@ -6,12 +6,18 @@ import {CompactSelect} from 'sentry/components/compactSelect';
 import DropdownButton from 'sentry/components/dropdownButton';
 import ErrorBoundary from 'sentry/components/errorBoundary';
 import {
+  ALPHA_OPTIONS,
   CardContainer,
+  EVAL_ORDER_OPTIONS,
   FeatureFlagDrawer,
-  FLAG_SORT_OPTIONS,
   FlagControlOptions,
   FlagSort,
-  getLabel,
+  getDefaultFlagSort,
+  getFlagSortLabel,
+  getSortGroupLabel,
+  SORT_GROUP_OPTIONS,
+  sortedFlags,
+  SortGroup,
 } from 'sentry/components/events/featureFlags/featureFlagDrawer';
 import useDrawer from 'sentry/components/globalDrawer';
 import KeyValueData, {
@@ -56,7 +62,8 @@ export function EventFeatureFlagList({
     </Button>
   ) : null;
 
-  const [sortMethod, setSortMethod] = useState<FlagSort>(FlagSort.NEWEST);
+  const [flagSort, setFlagSort] = useState<FlagSort>(FlagSort.NEWEST);
+  const [sortGroup, setSortGroup] = useState<SortGroup>(SortGroup.EVAL_ORDER);
   const {closeDrawer, isDrawerOpen, openDrawer} = useDrawer();
   const viewAllButtonRef = useRef<HTMLButtonElement>(null);
   const organization = useOrganization();
@@ -79,19 +86,6 @@ export function EventFeatureFlagList({
     [event]
   );
 
-  const handleSortAlphabetical = (flags: KeyValueDataContentProps[]) => {
-    return [...flags].sort((a, b) => {
-      return a.item.key.localeCompare(b.item.key);
-    });
-  };
-
-  const sortedFlags =
-    sortMethod === FlagSort.ALPHA
-      ? handleSortAlphabetical(hydratedFlags)
-      : sortMethod === FlagSort.OLDEST
-        ? [...hydratedFlags].reverse()
-        : hydratedFlags;
-
   const onViewAllFlags = useCallback(
     (focusControl?: FlagControlOptions) => {
       trackAnalytics('flags.view-all-clicked', {
@@ -104,7 +98,8 @@ export function EventFeatureFlagList({
             event={event}
             project={project}
             hydratedFlags={hydratedFlags}
-            initialSort={sortMethod}
+            initialSortGroup={sortGroup}
+            initialFlagSort={flagSort}
             focusControl={focusControl}
           />
         ),
@@ -123,7 +118,7 @@ export function EventFeatureFlagList({
         }
       );
     },
-    [openDrawer, event, group, project, sortMethod, hydratedFlags, organization]
+    [openDrawer, event, group, project, hydratedFlags, organization, flagSort, sortGroup]
   );
 
   if (!hydratedFlags.length) {
@@ -152,13 +147,29 @@ export function EventFeatureFlagList({
         {t('View All')}
       </Button>
       <CompactSelect
-        value={sortMethod}
-        options={FLAG_SORT_OPTIONS}
+        value={sortGroup}
+        options={SORT_GROUP_OPTIONS}
+        triggerProps={{
+          'aria-label': t('Sort Group'),
+        }}
+        onChange={selection => {
+          setFlagSort(getDefaultFlagSort(selection.value));
+          setSortGroup(selection.value);
+        }}
+        trigger={triggerProps => (
+          <DropdownButton {...triggerProps} size="xs">
+            {getSortGroupLabel(sortGroup)}
+          </DropdownButton>
+        )}
+      />
+      <CompactSelect
+        value={flagSort}
+        options={sortGroup === SortGroup.EVAL_ORDER ? EVAL_ORDER_OPTIONS : ALPHA_OPTIONS}
         triggerProps={{
-          'aria-label': t('Sort Flags'),
+          'aria-label': t('Flag Sort Type'),
         }}
         onChange={selection => {
-          setSortMethod(selection.value);
+          setFlagSort(selection.value);
           trackAnalytics('flags.sort-flags', {
             organization,
             sortMethod: selection.value,
@@ -166,7 +177,7 @@ export function EventFeatureFlagList({
         }}
         trigger={triggerProps => (
           <DropdownButton {...triggerProps} size="xs" icon={<IconSort />}>
-            {getLabel(sortMethod)}
+            {getFlagSortLabel(flagSort)}
           </DropdownButton>
         )}
       />
@@ -174,7 +185,7 @@ export function EventFeatureFlagList({
   );
 
   // Split the flags list into two columns for display
-  const truncatedItems = sortedFlags.slice(0, 20);
+  const truncatedItems = sortedFlags({flags: hydratedFlags, sort: flagSort}).slice(0, 20);
   const columnOne = truncatedItems.slice(0, 10);
   let columnTwo: typeof truncatedItems = [];
   if (truncatedItems.length > 10) {

+ 14 - 5
static/app/components/events/featureFlags/featureFlagDrawer.spec.tsx

@@ -42,7 +42,10 @@ describe('FeatureFlagDrawer', function () {
     // Header & Controls
     expect(drawerScreen.getByText('Feature Flags', {selector: 'h3'})).toBeInTheDocument();
     expect(drawerScreen.getByRole('textbox', {name: 'Search Flags'})).toBeInTheDocument();
-    expect(drawerScreen.getByRole('button', {name: 'Newest First'})).toBeInTheDocument();
+    expect(drawerScreen.getByRole('button', {name: 'Newest'})).toBeInTheDocument();
+    expect(
+      drawerScreen.getByRole('button', {name: 'Evaluation Order'})
+    ).toBeInTheDocument();
 
     // Contents
     for (const {flag, result} of MOCK_FLAGS) {
@@ -81,10 +84,10 @@ describe('FeatureFlagDrawer', function () {
 
     // the sort should be reversed
     const sortControl = drawerScreen.getByRole('button', {
-      name: 'Newest First',
+      name: 'Newest',
     });
     await userEvent.click(sortControl);
-    await userEvent.click(drawerScreen.getByRole('option', {name: 'Oldest First'}));
+    await userEvent.click(drawerScreen.getByRole('option', {name: 'Oldest'}));
 
     expect(
       drawerScreen
@@ -92,13 +95,19 @@ describe('FeatureFlagDrawer', function () {
         .compareDocumentPosition(drawerScreen.getByText(enableReplay.flag))
     ).toBe(document.DOCUMENT_POSITION_PRECEDING);
 
-    await userEvent.click(sortControl);
+    const sortGroupControl = drawerScreen.getByRole('button', {
+      name: 'Evaluation Order',
+    });
+    await userEvent.click(sortGroupControl);
     await userEvent.click(drawerScreen.getByRole('option', {name: 'Alphabetical'}));
+    await userEvent.click(sortControl);
+    await userEvent.click(drawerScreen.getByRole('option', {name: 'Z-A'}));
 
+    // webVitalsFlag comes after enableReplay alphabetically
     expect(
       drawerScreen
         .getByText(webVitalsFlag.flag)
         .compareDocumentPosition(drawerScreen.getByText(enableReplay.flag))
-    ).toBe(document.DOCUMENT_POSITION_FOLLOWING);
+    ).toBe(document.DOCUMENT_POSITION_PRECEDING);
   });
 });

+ 106 - 32
static/app/components/events/featureFlags/featureFlagDrawer.tsx

@@ -34,33 +34,73 @@ import useOrganization from 'sentry/utils/useOrganization';
 export enum FlagSort {
   NEWEST = 'newest',
   OLDEST = 'oldest',
-  ALPHA = 'alphabetical',
+  A_TO_Z = 'a-z',
+  Z_TO_A = 'z-a',
 }
 
-export const getLabel = (sort: string) => {
+export enum SortGroup {
+  EVAL_ORDER = 'eval',
+  ALPHABETICAL = 'alphabetical',
+}
+
+export const getFlagSortLabel = (sort: string) => {
   switch (sort) {
+    case FlagSort.A_TO_Z:
+      return t('A-Z');
+    case FlagSort.Z_TO_A:
+      return t('Z-A');
     case FlagSort.OLDEST:
-      return t('Oldest First');
-    case FlagSort.ALPHA:
-      return t('Alphabetical');
+      return t('Oldest');
     case FlagSort.NEWEST:
     default:
-      return t('Newest First');
+      return t('Newest');
+  }
+};
+
+export const getSortGroupLabel = (sort: string) => {
+  switch (sort) {
+    case SortGroup.ALPHABETICAL:
+      return t('Alphabetical');
+    case SortGroup.EVAL_ORDER:
+    default:
+      return t('Evaluation Order');
   }
 };
 
-export const FLAG_SORT_OPTIONS = [
+export const getDefaultFlagSort = (sortGroup: SortGroup) => {
+  return sortGroup === SortGroup.EVAL_ORDER ? FlagSort.NEWEST : FlagSort.A_TO_Z;
+};
+
+export const SORT_GROUP_OPTIONS = [
+  {
+    label: getSortGroupLabel(SortGroup.EVAL_ORDER),
+    value: SortGroup.EVAL_ORDER,
+  },
+  {
+    label: getSortGroupLabel(SortGroup.ALPHABETICAL),
+    value: SortGroup.ALPHABETICAL,
+  },
+];
+
+export const EVAL_ORDER_OPTIONS = [
   {
-    label: getLabel(FlagSort.NEWEST),
+    label: getFlagSortLabel(FlagSort.NEWEST),
     value: FlagSort.NEWEST,
   },
   {
-    label: getLabel(FlagSort.OLDEST),
+    label: getFlagSortLabel(FlagSort.OLDEST),
     value: FlagSort.OLDEST,
   },
+];
+
+export const ALPHA_OPTIONS = [
+  {
+    label: getFlagSortLabel(FlagSort.A_TO_Z),
+    value: FlagSort.A_TO_Z,
+  },
   {
-    label: getLabel(FlagSort.ALPHA),
-    value: FlagSort.ALPHA,
+    label: getFlagSortLabel(FlagSort.Z_TO_A),
+    value: FlagSort.Z_TO_A,
   },
 ];
 
@@ -69,11 +109,37 @@ export const enum FlagControlOptions {
   SORT = 'sort',
 }
 
+export const handleSortAlphabetical = (flags: KeyValueDataContentProps[]) => {
+  return [...flags].sort((a, b) => {
+    return a.item.key.localeCompare(b.item.key);
+  });
+};
+
+export const sortedFlags = ({
+  flags,
+  sort,
+}: {
+  flags: KeyValueDataContentProps[];
+  sort: FlagSort;
+}): KeyValueDataContentProps[] => {
+  switch (sort) {
+    case FlagSort.A_TO_Z:
+      return handleSortAlphabetical(flags);
+    case FlagSort.Z_TO_A:
+      return [...handleSortAlphabetical(flags)].reverse();
+    case FlagSort.OLDEST:
+      return [...flags].reverse();
+    default:
+      return flags;
+  }
+};
+
 interface FlagDrawerProps {
   event: Event;
   group: Group;
   hydratedFlags: KeyValueDataContentProps[];
-  initialSort: FlagSort;
+  initialFlagSort: FlagSort;
+  initialSortGroup: SortGroup;
   project: Project;
   focusControl?: FlagControlOptions;
 }
@@ -82,28 +148,20 @@ export function FeatureFlagDrawer({
   group,
   event,
   project,
-  initialSort,
+  initialFlagSort,
+  initialSortGroup,
   hydratedFlags,
   focusControl: initialFocusControl,
 }: FlagDrawerProps) {
-  const [sortMethod, setSortMethod] = useState<FlagSort>(initialSort);
+  const [sortGroup, setSortGroup] = useState<SortGroup>(initialSortGroup);
+  const [flagSort, setFlagSort] = useState<FlagSort>(initialFlagSort);
   const [search, setSearch] = useState('');
   const organization = useOrganization();
   const {getFocusProps} = useFocusControl(initialFocusControl);
 
-  const handleSortAlphabetical = (flags: KeyValueDataContentProps[]) => {
-    return [...flags].sort((a, b) => {
-      return a.item.key.localeCompare(b.item.key);
-    });
-  };
-
-  const sortedFlags =
-    sortMethod === FlagSort.ALPHA
-      ? handleSortAlphabetical(hydratedFlags)
-      : sortMethod === FlagSort.OLDEST
-        ? [...hydratedFlags].reverse()
-        : hydratedFlags;
-  const searchResults = sortedFlags.filter(f => f.item.key.includes(search));
+  const searchResults = sortedFlags({flags: hydratedFlags, sort: flagSort}).filter(f =>
+    f.item.key.includes(search)
+  );
 
   const actions = (
     <ButtonBar gap={1}>
@@ -122,11 +180,29 @@ export function FeatureFlagDrawer({
         </InputGroup.TrailingItems>
       </InputGroup>
       <CompactSelect
+        value={sortGroup}
+        options={SORT_GROUP_OPTIONS}
+        triggerProps={{
+          'aria-label': t('Sort Group'),
+        }}
+        onChange={selection => {
+          setFlagSort(getDefaultFlagSort(selection.value));
+          setSortGroup(selection.value);
+        }}
+        trigger={triggerProps => (
+          <DropdownButton {...triggerProps} size="xs">
+            {getSortGroupLabel(sortGroup)}
+          </DropdownButton>
+        )}
+      />
+      <CompactSelect
+        value={flagSort}
+        options={sortGroup === SortGroup.EVAL_ORDER ? EVAL_ORDER_OPTIONS : ALPHA_OPTIONS}
         triggerProps={{
-          'aria-label': t('Sort Flags'),
+          'aria-label': t('Flag Sort Type'),
         }}
         onChange={selection => {
-          setSortMethod(selection.value);
+          setFlagSort(selection.value);
           trackAnalytics('flags.sort-flags', {
             organization,
             sortMethod: selection.value,
@@ -134,11 +210,9 @@ export function FeatureFlagDrawer({
         }}
         trigger={triggerProps => (
           <DropdownButton {...triggerProps} size="xs" icon={<IconSort />}>
-            {getLabel(sortMethod)}
+            {getFlagSortLabel(flagSort)}
           </DropdownButton>
         )}
-        value={sortMethod}
-        options={FLAG_SORT_OPTIONS}
       />
     </ButtonBar>
   );