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 };