github.com/grafana/pyroscope@v1.18.0/public/app/components/AppSelector/AppSelector.tsx (about)

     1  import React, { useState, useEffect, useMemo } from 'react';
     2  import ModalWithToggle from '@pyroscope/ui/Modals/ModalWithToggle';
     3  import { App, appFromQuery, appToQuery } from '@pyroscope/models/app';
     4  import { Query } from '@pyroscope/models/query';
     5  import cx from 'classnames';
     6  import SelectButton from '@pyroscope/components/AppSelector/SelectButton';
     7  import ogStyles from './AppSelector.module.scss';
     8  import styles from './AppSelector.module.css';
     9  
    10  // type App = Omit<OgApp, 'name'>;
    11  
    12  interface AppSelectorProps {
    13    /** Triggered when an app is selected */
    14    onSelected: (query: Query) => void;
    15  
    16    /** List of all applications */
    17    apps: App[];
    18  
    19    selectedQuery: Query;
    20  }
    21  
    22  // TODO: unify this with public/app/overrides/services/apps.ts
    23  function uniqueByName(apps: App[]) {
    24    const idFn = (b: App) => b.name;
    25    const visited = new Set<string>();
    26  
    27    return apps.filter((b) => {
    28      if (visited.has(idFn(b))) {
    29        return false;
    30      }
    31  
    32      visited.add(idFn(b));
    33      return true;
    34    });
    35  }
    36  
    37  function findAppsWithName(apps: App[], appName: string) {
    38    return apps.filter((a) => {
    39      return a.name === appName;
    40    });
    41  }
    42  
    43  function queryToApp(query: Query, apps: App[]) {
    44    const maybeSelectedApp = appFromQuery(query);
    45    if (!maybeSelectedApp) {
    46      return undefined;
    47    }
    48  
    49    return apps.find(
    50      (a) =>
    51        a.__profile_type__ === maybeSelectedApp?.__profile_type__ &&
    52        a.name === maybeSelectedApp?.name
    53    );
    54  }
    55  
    56  export function AppSelector({
    57    onSelected,
    58    apps,
    59    selectedQuery,
    60  }: AppSelectorProps) {
    61    const maybeSelectedApp = queryToApp(selectedQuery, apps);
    62    const [filter, setFilter] = useState('');
    63    const filteredApps = useMemo(
    64      () =>
    65        apps.filter((app) =>
    66          app.name.toLowerCase().includes(filter.trim().toLowerCase())
    67        ),
    68      [apps, filter]
    69    );
    70    useEffect(() => {
    71      setFilter('');
    72    }, [selectedQuery]);
    73  
    74    return (
    75      <div className={ogStyles.container}>
    76        <SelectorModalWithToggler
    77          apps={filteredApps}
    78          onSelected={(app) => onSelected(appToQuery(app))}
    79          selectedApp={maybeSelectedApp}
    80          filter={filter}
    81          setFilter={setFilter}
    82        />
    83      </div>
    84    );
    85  }
    86  
    87  export const SelectorModalWithToggler = ({
    88    apps,
    89    selectedApp,
    90    onSelected: onSelectedUpstream,
    91    filter,
    92    setFilter,
    93  }: {
    94    apps: App[];
    95    selectedApp?: App;
    96    onSelected: (app: App) => void;
    97    filter: string;
    98    setFilter: (filter: string) => void;
    99  }) => {
   100    const onSelected = (app: App) => {
   101      // Reset state
   102      setSelectedLeftSide(undefined);
   103  
   104      onSelectedUpstream(app);
   105      setModalOpenStatus(false);
   106    };
   107  
   108    const leftSideApps = uniqueByName(apps);
   109    const [isModalOpen, setModalOpenStatus] = useState(false);
   110    const [selectedLeftSide, setSelectedLeftSide] = useState<string>();
   111    const matchedApps = findAppsWithName(
   112      apps,
   113      selectedLeftSide || selectedApp?.name || ''
   114    );
   115    const label = 'Select an application';
   116  
   117    // For the left side, it's possible to be selected either via
   118    // * The current query (ie. just opened the component)
   119    // * The current "expanded state" (ie. clicked on the left side)
   120    const isLeftSideSelected = (a: App) => {
   121      if (selectedLeftSide) {
   122        return selectedLeftSide === a.name;
   123      }
   124  
   125      return selectedApp?.name === a.name;
   126    };
   127  
   128    // For the right side, the only way to be selected is if matches the current query
   129    // Since clicking on an item sets that app as the current query
   130    const isRightSideSelected = (a: App) => {
   131      if (selectedLeftSide) {
   132        return false;
   133      }
   134  
   135      return selectedApp?.__profile_type__ === a.__profile_type__;
   136    };
   137  
   138    const groups = useMemo(() => {
   139      const allGroups = leftSideApps.map((app) => app.name.split('-')[0]);
   140      const uniqGroups = Array.from(new Set(allGroups));
   141  
   142      const dedupedUniqGroups = uniqGroups.filter((x) => {
   143        return !uniqGroups.find((y) => x !== y && y.startsWith(x));
   144      });
   145  
   146      const groupOrApp = dedupedUniqGroups.map((groupName) => {
   147        const appNamesEntries = leftSideApps.filter((app) =>
   148          app.name.startsWith(groupName)
   149        );
   150  
   151        return appNamesEntries.length > 1 ? groupName : appNamesEntries[0].name;
   152      });
   153  
   154      return groupOrApp;
   155    }, [leftSideApps]);
   156  
   157    const listHeight = useMemo(() => {
   158      const windowHeight = window?.innerHeight || 0;
   159      const listRequiredHeight = Math.max(groups.length, matchedApps.length) * 35;
   160  
   161      if (windowHeight && listRequiredHeight) {
   162        return windowHeight >= listRequiredHeight ? 'auto' : `${windowHeight}px`;
   163      }
   164  
   165      return 'auto';
   166    }, [groups, matchedApps]);
   167  
   168    return (
   169      <ModalWithToggle
   170        isModalOpen={isModalOpen}
   171        setModalOpenStatus={setModalOpenStatus}
   172        modalClassName={cx(ogStyles.appSelectorModal, styles.appSelectorModal)}
   173        customHandleOutsideClick={() => {
   174          setSelectedLeftSide(undefined);
   175          setModalOpenStatus(false);
   176        }}
   177        modalHeight={listHeight}
   178        noDataEl={
   179          !leftSideApps?.length ? (
   180            <div data-testid="app-selector-no-data" className={ogStyles.noData}>
   181              No Data
   182            </div>
   183          ) : null
   184        }
   185        toggleText={
   186          selectedApp
   187            ? `${selectedApp?.name}:${selectedApp.__name__}:${selectedApp.__type__}`
   188            : label
   189        }
   190        headerEl={
   191          <>
   192            <div className={ogStyles.headerTitle}>{label}</div>
   193            <input
   194              type="text"
   195              placeholder="Search..."
   196              value={filter}
   197              onChange={(e) => setFilter(e.target.value)}
   198              className={ogStyles.search}
   199              data-testid="app-selector-search"
   200            />
   201          </>
   202        }
   203        leftSideEl={leftSideApps.map((app) => (
   204          <SelectButton
   205            name={app.name}
   206            onClick={() => {
   207              setSelectedLeftSide(app.name);
   208            }}
   209            icon="folder"
   210            isSelected={isLeftSideSelected(app)}
   211            key={app.name}
   212          />
   213        ))}
   214        rightSideEl={matchedApps.map((app) => (
   215          <SelectButton
   216            name={`${app.__name__}:${app.__type__}`}
   217            icon="pyroscope"
   218            onClick={() => onSelected(app)}
   219            isSelected={isRightSideSelected(app)}
   220            key={app.__profile_type__}
   221          />
   222        ))}
   223      />
   224    );
   225  };