Browse Source

ref(page-filters): Use counter badge & better trimming in project filter (#33002)

* ref(page-filters): Use counter badge in project filter

* ref(page-filters): Trim project slugs from middle

* ref(utils): Use unicode character for ellipsis in trimSlug

* ref(page-filters): Show 2 project slugs if there's space

* feat(page-filters): Add maxTitleLength prop to project page filter

* style(lint): Auto commit lint changes

* style(page-filters): Add comments explaining projectsToShow

* ref(nav-tabs): Center align content inside each tab

* ref(trimSlug): Move into own file & add test

Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com>
Vu Luong 2 years ago
parent
commit
c11ad8e279

+ 0 - 1
static/app/components/badge.tsx

@@ -27,7 +27,6 @@ const Badge = styled(({children, text, ...props}: Props) => (
   transition: background 100ms linear;
 
   position: relative;
-  top: -1px;
 `;
 
 export default Badge;

+ 33 - 5
static/app/components/projectPageFilter.tsx

@@ -5,6 +5,7 @@ import isEqual from 'lodash/isEqual';
 import partition from 'lodash/partition';
 
 import {updateProjects} from 'sentry/actionCreators/pageFilters';
+import Badge from 'sentry/components/badge';
 import MultipleProjectSelector from 'sentry/components/organizations/multipleProjectSelector';
 import PageFilterDropdownButton from 'sentry/components/organizations/pageFilters/pageFilterDropdownButton';
 import PlatformList from 'sentry/components/platformList';
@@ -16,6 +17,7 @@ import PageFiltersStore from 'sentry/stores/pageFiltersStore';
 import {useLegacyStore} from 'sentry/stores/useLegacyStore';
 import space from 'sentry/styles/space';
 import {MinimalProject} from 'sentry/types';
+import {trimSlug} from 'sentry/utils/trimSlug';
 import useOrganization from 'sentry/utils/useOrganization';
 import useProjects from 'sentry/utils/useProjects';
 
@@ -36,6 +38,12 @@ type Props = WithRouterProps & {
    */
   lockedMessageSubject?: string;
 
+  /**
+   * Max character length for the dropdown title. Default is 20. This number
+   * is used to determine how many projects to show, and how much to truncate.
+   */
+  maxTitleLength?: number;
+
   /**
    * A project will be forced from parent component (selection is disabled, and if user
    * does not have multi-project support enabled, it will not try to auto select a project).
@@ -61,7 +69,12 @@ type Props = WithRouterProps & {
   specificProjectSlugs?: string[];
 };
 
-export function ProjectPageFilter({router, specificProjectSlugs, ...otherProps}: Props) {
+export function ProjectPageFilter({
+  router,
+  specificProjectSlugs,
+  maxTitleLength = 20,
+  ...otherProps
+}: Props) {
   const [currentSelectedProjects, setCurrentSelectedProjects] = useState<number[] | null>(
     null
   );
@@ -104,15 +117,24 @@ export function ProjectPageFilter({router, specificProjectSlugs, ...otherProps}:
   const customProjectDropdown = ({getActorProps, selectedProjects, isOpen}) => {
     const selectedProjectIds = new Set(selection.projects);
     const hasSelected = !!selectedProjects.length;
+
+    // Show 2 projects only if the combined string does not exceed maxTitleLength.
+    // Otherwise show only 1 project.
+    const projectsToShow =
+      selectedProjects[0]?.slug?.length + selectedProjects[1]?.slug?.length <=
+      maxTitleLength - 2
+        ? selectedProjects.slice(0, 2)
+        : selectedProjects.slice(0, 1);
+
     const title = hasSelected
-      ? selectedProjects.map(({slug}) => slug).join(', ')
+      ? projectsToShow.map(proj => trimSlug(proj.slug, maxTitleLength)).join(', ')
       : selectedProjectIds.has(ALL_ACCESS_PROJECTS)
       ? t('All Projects')
       : t('My Projects');
+
     const icon = hasSelected ? (
       <PlatformList
-        platforms={selectedProjects.map(p => p.platform ?? 'other').reverse()}
-        max={5}
+        platforms={projectsToShow.map(p => p.platform ?? 'other').reverse()}
       />
     ) : (
       <IconProject />
@@ -128,6 +150,9 @@ export function ProjectPageFilter({router, specificProjectSlugs, ...otherProps}:
         <DropdownTitle>
           {icon}
           <TitleContainer>{title}</TitleContainer>
+          {selectedProjects.length > projectsToShow.length && (
+            <StyledBadge text={`+${selectedProjects.length - projectsToShow.length}`} />
+          )}
         </DropdownTitle>
       </PageFilterDropdownButton>
     );
@@ -171,9 +196,12 @@ const TitleContainer = styled('div')`
 const DropdownTitle = styled('div')`
   width: max-content;
   display: flex;
-  overflow: hidden;
   align-items: center;
   flex: 1;
 `;
 
+const StyledBadge = styled(Badge)`
+  flex-shrink: 0;
+`;
+
 export default withRouter(ProjectPageFilter);

+ 49 - 0
static/app/utils/trimSlug.tsx

@@ -0,0 +1,49 @@
+/**
+ * Trim slug name with a preference for preserving whole words. Only cut up
+ * whole words if the last remaining words are still too long. For example:
+ * "javascript-project-backend" --> "javascript…backend"
+ * "my-long-sentry-project-name" --> "my-long-sentry…name"
+ * "javascriptproject-backend" --> "javascriptproje…ckend"
+ */
+export function trimSlug(slug: string, maxLength: number = 20) {
+  // Return the original slug if it's already shorter than maxLength
+  if (slug.length <= maxLength) {
+    return slug;
+  }
+
+  /**
+   * Array of words inside the slug.
+   * E.g. "my-project-name" becomes ["my", "project", "name"]
+   */
+  const words: string[] = slug.split('-');
+  /**
+   * Returns the length (total number of letters plus hyphens in between
+   * words) of the current words array.
+   */
+  function getLength(arr: string[]): number {
+    return arr.reduce((acc, cur) => acc + cur.length + 1, 0) - 1;
+  }
+
+  // Progressively remove words in the middle until we're below maxLength,
+  // or when only two words are left
+  while (getLength(words) > maxLength && words.length > 2) {
+    words.splice(-2, 1);
+  }
+
+  // If the remaining words array satisfies the maxLength requirement,
+  // return the trimmed result.
+  if (getLength(words) <= maxLength) {
+    return `${words.slice(0, -1).join('-')}\u2026${words[words.length - 1]}`;
+  }
+
+  // If the remaining 2 words are still too long, trim those words starting
+  // from the middle.
+  const debt = getLength(words) - maxLength;
+  const toTrimFromLeftWord = Math.ceil(debt / 2);
+  const leftWordLength = Math.max(words[0].length - toTrimFromLeftWord, 3);
+  const leftWord = words[0].slice(0, leftWordLength);
+  const rightWordLength = maxLength - leftWord.length;
+  const rightWord = words[1].slice(-rightWordLength);
+
+  return `${leftWord}\u2026${rightWord}`;
+}

+ 2 - 0
static/less/shared-components.less

@@ -1112,6 +1112,8 @@ header + .alert {
     }
 
     > a {
+      display: flex;
+      align-items: center;
       padding: 0 0 10px;
       margin: 0;
       border: 0;

+ 18 - 0
tests/js/spec/utils/trimSlug.spec.tsx

@@ -0,0 +1,18 @@
+import {trimSlug} from 'sentry/utils/trimSlug';
+
+describe('trimSlug', function () {
+  it('returns slug if it is already short enough', function () {
+    expect(trimSlug('javascript', 20)).toBe('javascript');
+  });
+
+  it('trims slug from the middle, preserves whole words', function () {
+    expect(trimSlug('symbol-collector-console', 20)).toBe('symbol…console');
+    expect(trimSlug('symbol-collector-mobile', 20)).toBe('symbol…mobile');
+    expect(trimSlug('visual-snapshot-cloud-run', 20)).toBe('visual-snapshot…run');
+  });
+
+  it('trims slug from the middle, cuts whole words', function () {
+    expect(trimSlug('sourcemapsio-javascript', 20)).toBe('sourcemaps…javascript');
+    expect(trimSlug('armcknight-ios-ephemeraldemo', 20)).toBe('armcknig…phemeraldemo');
+  });
+});