Browse Source

feat(ui) Port oxfordizeArray from getsentry (#56281)

Bring this utility module to sentry and replace bespoke implementations
with the new function. I'll remove the getsentry twin after this merges.
Mark Story 1 year ago
parent
commit
2078c12b89

+ 4 - 4
static/app/components/charts/utils.tsx

@@ -8,6 +8,7 @@ import {Series} from 'sentry/types/echarts';
 import {defined, escape} from 'sentry/utils';
 import {getFormattedDate, parsePeriodToHours} from 'sentry/utils/dates';
 import type {TableDataWithTitle} from 'sentry/utils/discover/discoverQuery';
+import oxfordizeArray from 'sentry/utils/oxfordizeArray';
 import {decodeList} from 'sentry/utils/queryString';
 
 const DEFAULT_TRUNCATE_LENGTH = 80;
@@ -321,10 +322,9 @@ export const getPreviousSeriesName = (seriesName: string) => {
 };
 
 function formatList(items: Array<string | number | undefined>) {
-  const filteredItems = items.filter(item => !!item);
-  return [[...filteredItems].slice(0, -1).join(', '), [...filteredItems].slice(-1)]
-    .filter(type => !!type)
-    .join(' and ');
+  const filteredItems = items.filter((item): item is string | number => !!item);
+
+  return oxfordizeArray(filteredItems.map(item => item.toString()));
 }
 
 export function useEchartsAriaLabels(

+ 2 - 4
static/app/components/organizations/pageFilters/desyncedFiltersAlert.tsx

@@ -9,6 +9,7 @@ import {IconClose} from 'sentry/icons';
 import {t, tct} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
 import {PinnedPageFilter} from 'sentry/types';
+import oxfordizeArray from 'sentry/utils/oxfordizeArray';
 import useOrganization from 'sentry/utils/useOrganization';
 import usePageFilters from 'sentry/utils/usePageFilters';
 
@@ -31,10 +32,7 @@ function getReadableDesyncedFilterList(desyncedFilters: Set<PinnedPageFilter>) {
     return `${filterNameMap[filters[0]]} filter`;
   }
 
-  return `${filters
-    .slice(0, -1)
-    .map(value => filterNameMap[value])
-    .join(', ')} and ${filterNameMap[filters[filters.length - 1]]} filters`;
+  return oxfordizeArray(filters.map(value => filterNameMap[value])) + ' filters';
 }
 
 export default function DesyncedFilterAlert({

+ 53 - 0
static/app/utils/oxfordizeArray.spec.tsx

@@ -0,0 +1,53 @@
+import {render, screen} from 'sentry-test/reactTestingLibrary';
+
+import oxfordizeArray, {Oxfordize} from 'sentry/utils/oxfordizeArray';
+
+describe('oxfordizeArray', function () {
+  it('correctly formats lists of strings', function () {
+    const zero = [];
+    const one = ['A'];
+    const two = ['A', 'B'];
+    const three = ['A', 'B', 'C'];
+    const four = ['A', 'B', 'C', 'D'];
+
+    expect(oxfordizeArray(zero)).toEqual('');
+    expect(oxfordizeArray(one)).toEqual('A');
+    expect(oxfordizeArray(two)).toEqual('A and B');
+    expect(oxfordizeArray(three)).toEqual('A, B, and C');
+    expect(oxfordizeArray(four)).toEqual('A, B, C, and D');
+  });
+});
+
+describe('Oxfordize', function () {
+  it('correctly formats lists of elements', function () {
+    const items = [<i key="1">one</i>, <i key="2">two</i>, <i key="3">three</i>];
+    render(<Oxfordize>{items}</Oxfordize>);
+
+    expect(screen.getByText('one')).toBeInTheDocument();
+    expect(screen.getByText('two')).toBeInTheDocument();
+    expect(screen.getByText('three')).toBeInTheDocument();
+    expect(screen.getByText(/, and/)).toBeInTheDocument();
+  });
+
+  it('correctly formats one element', function () {
+    const items = ['one'];
+    render(<Oxfordize>{items}</Oxfordize>);
+
+    expect(screen.getByText('one')).toBeInTheDocument();
+  });
+
+  it('correctly formats two elements', function () {
+    const items = ['one', 'two'];
+    render(<Oxfordize>{items}</Oxfordize>);
+
+    expect(screen.getByText('one and two')).toBeInTheDocument();
+  });
+
+  it('correctly formats mixed lists of nodes', function () {
+    const items = [<i key="1">one</i>, 'two'];
+    render(<Oxfordize>{items}</Oxfordize>);
+
+    expect(screen.getByText('one')).toBeInTheDocument();
+    expect(screen.getByText(/and two/)).toBeInTheDocument();
+  });
+});

+ 64 - 0
static/app/utils/oxfordizeArray.tsx

@@ -0,0 +1,64 @@
+import {Children, Fragment} from 'react';
+
+// Given a list of strings (probably nouns), join them into a single string
+// with correct punctuation and 'and' placement
+//
+// for example: ['A'] --> 'A'
+//              ['A', 'B'] --> 'A and B'
+//              ['A', 'B', 'C'] --> 'A, B, and C'
+const oxfordizeArray = (strings: string[]) =>
+  strings.length <= 2
+    ? strings.join(' and ')
+    : [strings.slice(0, -1).join(', '), strings.slice(-1)[0]].join(', and ');
+
+export const oxfordizeElements = (elements: JSX.Element[]): JSX.Element => {
+  if (elements.length === 0) {
+    return <span />;
+  }
+  if (elements.length === 1) {
+    return elements[0];
+  }
+  if (elements.length === 2) {
+    return (
+      <span>
+        {elements[0]} and {elements[1]}
+      </span>
+    );
+  }
+  const joinedElements: JSX.Element[] = [];
+  for (const [i, element] of elements.slice(0, -1).entries()) {
+    joinedElements.push(<Fragment key={i}>{element}, </Fragment>);
+  }
+  joinedElements.push(
+    <Fragment key={elements.length - 1}>and {elements[elements.length - 1]}</Fragment>
+  );
+  return <span>{joinedElements}</span>;
+};
+
+type Props = {
+  children: React.ReactNode;
+};
+export function Oxfordize({children}: Props) {
+  const elements = Children.toArray(children);
+  if (elements.length === 1) {
+    return <span>{elements[0]}</span>;
+  }
+  if (elements.length === 2) {
+    return (
+      <span>
+        {elements[0]} and {elements[1]}
+      </span>
+    );
+  }
+
+  const joinedElements: JSX.Element[] = [];
+  for (const [i, element] of elements.slice(0, -1).entries()) {
+    joinedElements.push(<Fragment key={i}>{element}, </Fragment>);
+  }
+  joinedElements.push(
+    <Fragment key={elements.length - 1}>and {elements[elements.length - 1]}</Fragment>
+  );
+  return <span>{joinedElements}</span>;
+}
+
+export default oxfordizeArray;

+ 2 - 3
static/app/views/settings/account/accountSecurity/index.tsx

@@ -18,6 +18,7 @@ import {IconDelete} from 'sentry/icons';
 import {t} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
 import {Authenticator, OrganizationSummary} from 'sentry/types';
+import oxfordizeArray from 'sentry/utils/oxfordizeArray';
 import recreateRoute from 'sentry/utils/recreateRoute';
 import DeprecatedAsyncView from 'sentry/views/deprecatedAsyncView';
 import RemoveConfirm from 'sentry/views/settings/account/accountSecurity/components/removeConfirm';
@@ -66,9 +67,7 @@ class AccountSecurity extends DeprecatedAsyncView<Props> {
     const {orgsRequire2fa} = this.props;
     const slugs = orgsRequire2fa.map(({slug}) => slug);
 
-    return [slugs.slice(0, -1).join(', '), slugs.slice(-1)[0]].join(
-      slugs.length > 1 ? ' and ' : ''
-    );
+    return oxfordizeArray(slugs);
   };
 
   handleAdd2FAClicked = () => {