github.com/tilt-dev/tilt@v0.33.15-0.20240515162809-0a22ed45d8a0/web/src/SidebarResources.tsx (about) 1 import { 2 Accordion, 3 AccordionDetails, 4 AccordionSummary, 5 } from "@material-ui/core" 6 import React, { ChangeEvent, useCallback, useState } from "react" 7 import { Link } from "react-router-dom" 8 import styled from "styled-components" 9 import { AnalyticsType, Tags } from "./analytics" 10 import { 11 DEFAULT_RESOURCE_LIST_LIMIT, 12 RESOURCE_LIST_MULTIPLIER, 13 } from "./constants" 14 import { FeaturesContext, Flag, useFeatures } from "./feature" 15 import { 16 GroupByLabelView, 17 orderLabels, 18 TILTFILE_LABEL, 19 UNLABELED_LABEL, 20 } from "./labels" 21 import { OverviewSidebarOptions } from "./OverviewSidebarOptions" 22 import PathBuilder from "./PathBuilder" 23 import { 24 AccordionDetailsStyleResetMixin, 25 AccordionStyleResetMixin, 26 AccordionSummaryStyleResetMixin, 27 ResourceGroupsInfoTip, 28 ResourceGroupSummaryIcon, 29 ResourceGroupSummaryMixin, 30 } from "./ResourceGroups" 31 import { useResourceGroups } from "./ResourceGroupsContext" 32 import { ResourceListOptions } from "./ResourceListOptionsContext" 33 import { matchesResourceName } from "./ResourceNameFilter" 34 import { SidebarGroupStatusSummary } from "./ResourceStatusSummary" 35 import { ShowMoreButton } from "./ShowMoreButton" 36 import SidebarItem from "./SidebarItem" 37 import SidebarItemView, { 38 sidebarItemIsDisabled, 39 SidebarItemRoot, 40 } from "./SidebarItemView" 41 import SidebarKeyboardShortcuts from "./SidebarKeyboardShortcuts" 42 import { AnimDuration, Color, Font, FontSize, SizeUnit } from "./style-helpers" 43 import { startBuild } from "./trigger" 44 import { ResourceName, ResourceStatus, ResourceView } from "./types" 45 import { useStarredResources } from "./StarredResourcesContext" 46 47 export type SidebarProps = { 48 items: SidebarItem[] 49 selected: string 50 resourceView: ResourceView 51 pathBuilder: PathBuilder 52 resourceListOptions: ResourceListOptions 53 } 54 55 type SidebarGroupedByProps = SidebarProps & { 56 onStartBuild: () => void 57 } 58 59 type SidebarSectionProps = { 60 sectionName?: string 61 groupView?: boolean 62 } & SidebarProps 63 64 export let SidebarResourcesRoot = styled.nav` 65 flex: 1 0 auto; 66 67 &.isOverview { 68 overflow: auto; 69 flex-shrink: 1; 70 } 71 ` 72 73 let SidebarResourcesContent = styled.div` 74 margin-bottom: ${SizeUnit(1.75)}; 75 ` 76 77 let SidebarListSectionName = styled.div` 78 margin-top: ${SizeUnit(0.5)}; 79 margin-left: ${SizeUnit(0.5)}; 80 text-transform: uppercase; 81 color: ${Color.gray50}; 82 font-size: ${FontSize.small}; 83 ` 84 85 const BuiltinResourceLinkRoot = styled(Link)` 86 background-color: ${Color.gray40}; 87 border: 1px solid ${Color.gray50}; 88 border-radius: ${SizeUnit(1 / 8)}; 89 color: ${Color.white}; 90 display: block; 91 font-family: ${Font.sansSerif}; 92 font-size: ${FontSize.smallest}; 93 font-weight: normal; 94 margin: ${SizeUnit(1 / 3)} ${SizeUnit(1 / 2)}; 95 padding: ${SizeUnit(1 / 5)} ${SizeUnit(1 / 3)}; 96 text-decoration: none; 97 transition: all ${AnimDuration.default} ease; 98 99 &:is(:hover, :focus, :active) { 100 background-color: ${Color.gray30}; 101 } 102 103 &.isSelected { 104 background-color: ${Color.gray70}; 105 color: ${Color.gray30}; 106 font-weight: 600; 107 } 108 ` 109 110 export const SidebarListSectionItemsRoot = styled.ul` 111 margin-top: ${SizeUnit(0.25)}; 112 list-style: none; 113 ` 114 115 export const SidebarDisabledSectionList = styled.li` 116 color: ${Color.gray60}; 117 font-family: ${Font.sansSerif}; 118 font-size: ${FontSize.small}; 119 ` 120 121 export const SidebarDisabledSectionTitle = styled.span` 122 display: inline-block; 123 margin-bottom: ${SizeUnit(1 / 12)}; 124 margin-top: ${SizeUnit(1 / 3)}; 125 padding-left: ${SizeUnit(3 / 4)}; 126 ` 127 128 const NoMatchesFound = styled.li` 129 margin-left: ${SizeUnit(0.5)}; 130 color: ${Color.grayLightest}; 131 ` 132 133 const SidebarLabelSection = styled(Accordion)` 134 ${AccordionStyleResetMixin} 135 136 /* Set specific margins for sidebar */ 137 &.MuiAccordion-root, 138 &.MuiAccordion-root.Mui-expanded { 139 margin: ${SizeUnit(1 / 3)} ${SizeUnit(1 / 2)}; 140 } 141 ` 142 143 const SidebarGroupSummary = styled(AccordionSummary)` 144 ${AccordionSummaryStyleResetMixin} 145 ${ResourceGroupSummaryMixin} 146 147 /* Set specific background and borders for sidebar */ 148 .MuiAccordionSummary-content { 149 background-color: ${Color.gray40}; 150 border: 1px solid ${Color.gray50}; 151 border-radius: ${SizeUnit(1 / 8)}; 152 font-size: ${FontSize.small}; 153 } 154 ` 155 156 export const SidebarGroupName = styled.span` 157 margin-right: auto; 158 overflow: hidden; 159 text-overflow: ellipsis; 160 width: 100%; 161 ` 162 163 const SidebarGroupDetails = styled(AccordionDetails)` 164 ${AccordionDetailsStyleResetMixin} 165 166 &.MuiAccordionDetails-root { 167 ${SidebarItemRoot} { 168 margin-right: unset; 169 } 170 } 171 ` 172 173 const GROUP_INFO_TOOLTIP_ID = "sidebar-groups-info" 174 175 function onlyEnabledItems(items: SidebarItem[]): SidebarItem[] { 176 return items.filter((item) => !sidebarItemIsDisabled(item)) 177 } 178 function onlyDisabledItems(items: SidebarItem[]): SidebarItem[] { 179 return items.filter((item) => sidebarItemIsDisabled(item)) 180 } 181 function enabledItemsFirst(items: SidebarItem[]): SidebarItem[] { 182 let result = onlyEnabledItems(items) 183 result.push(...onlyDisabledItems(items)) 184 return result 185 } 186 187 function AllResourcesLink(props: { 188 pathBuilder: PathBuilder 189 selected: string 190 }) { 191 const isSelectedClass = props.selected === "" ? "isSelected" : "" 192 return ( 193 <BuiltinResourceLinkRoot 194 className={isSelectedClass} 195 aria-label="View all resource logs" 196 to={props.pathBuilder.encpath`/r/(all)/overview`} 197 > 198 All Resources 199 </BuiltinResourceLinkRoot> 200 ) 201 } 202 203 function StarredResourcesLink(props: { 204 pathBuilder: PathBuilder 205 selected: string 206 }) { 207 const starContext = useStarredResources() 208 if (!starContext.starredResources.length) { 209 return null 210 } 211 const isSelectedClass = 212 props.selected === ResourceName.starred ? "isSelected" : "" 213 return ( 214 <BuiltinResourceLinkRoot 215 className={isSelectedClass} 216 aria-label="View starred resource logs" 217 to={props.pathBuilder.encpath`/r/(starred)/overview`} 218 > 219 Starred Resources 220 </BuiltinResourceLinkRoot> 221 ) 222 } 223 224 export function SidebarListSection(props: SidebarSectionProps): JSX.Element { 225 const features = useFeatures() 226 const sectionName = props.sectionName ? ( 227 <SidebarListSectionName>{props.sectionName}</SidebarListSectionName> 228 ) : null 229 230 const resourceNameFilterApplied = 231 props.resourceListOptions.resourceNameFilter.length > 0 232 if (props.items.length === 0 && resourceNameFilterApplied) { 233 return ( 234 <> 235 {sectionName} 236 <SidebarListSectionItemsRoot> 237 <NoMatchesFound>No matching resources</NoMatchesFound> 238 </SidebarListSectionItemsRoot> 239 </> 240 ) 241 } 242 243 // TODO(nick): Figure out how to memoize filters effectively. 244 const enabledItems = onlyEnabledItems(props.items) 245 const disabledItems = onlyDisabledItems(props.items) 246 247 const displayDisabledResources = disabledItems.length > 0 248 249 return ( 250 <> 251 {sectionName} 252 <SidebarListSectionItemsRoot> 253 <SidebarListSectionItems {...props} items={enabledItems} /> 254 255 {displayDisabledResources && ( 256 <SidebarDisabledSectionList aria-label="Disabled resources"> 257 <SidebarDisabledSectionTitle>Disabled</SidebarDisabledSectionTitle> 258 <ul> 259 <SidebarListSectionItems {...props} items={disabledItems} /> 260 </ul> 261 </SidebarDisabledSectionList> 262 )} 263 </SidebarListSectionItemsRoot> 264 </> 265 ) 266 } 267 268 const ShowMoreRow = styled.li` 269 margin: ${SizeUnit(0.5)} ${SizeUnit(0.5)} 0 ${SizeUnit(0.5)}; 270 display: flex; 271 align-items: center; 272 justify-content: right; 273 ` 274 const DETAIL_TYPE_TAGS: Tags = { type: AnalyticsType.Detail } 275 276 function SidebarListSectionItems(props: SidebarSectionProps) { 277 let [maxItems, setMaxItems] = useState(DEFAULT_RESOURCE_LIST_LIMIT) 278 let displayItems = props.items 279 let moreItems = Math.max(displayItems.length - maxItems, 0) 280 if (moreItems) { 281 displayItems = displayItems.slice(0, maxItems) 282 } 283 284 let showMore = useCallback(() => { 285 setMaxItems(maxItems * RESOURCE_LIST_MULTIPLIER) 286 }, [maxItems, setMaxItems]) 287 288 let showMoreItemsButton = null 289 if (moreItems > 0) { 290 showMoreItemsButton = ( 291 <ShowMoreRow> 292 <ShowMoreButton 293 onClick={showMore} 294 analyticsTags={DETAIL_TYPE_TAGS} 295 currentListSize={maxItems} 296 itemCount={props.items.length} 297 /> 298 </ShowMoreRow> 299 ) 300 } 301 302 return ( 303 <> 304 {displayItems.map((item) => ( 305 <SidebarItemView 306 key={"sidebarItem-" + item.name} 307 groupView={props.groupView} 308 item={item} 309 selected={props.selected === item.name} 310 pathBuilder={props.pathBuilder} 311 resourceView={props.resourceView} 312 /> 313 ))} 314 {showMoreItemsButton} 315 </> 316 ) 317 } 318 319 function SidebarGroupListSection(props: { label: string } & SidebarProps) { 320 if (props.items.length === 0) { 321 return null 322 } 323 324 const formattedLabel = 325 props.label === UNLABELED_LABEL ? <em>{props.label}</em> : props.label 326 const labelNameId = `sidebarItem-${props.label}` 327 328 const { getGroup, toggleGroupExpanded } = useResourceGroups() 329 let { expanded } = getGroup(props.label) 330 331 let isSelected = props.items.some((item) => item.name == props.selected) 332 333 if (isSelected) { 334 // If an item in the group is selected, expand the group 335 // without writing it back to persistent state. 336 // 337 // This creates a nice interaction, where if you're keyboard-navigating 338 // through sidebar items, we expand the group you navigate into and expand 339 // it when you navigate out again. 340 expanded = true 341 } 342 343 const handleChange = (_e: ChangeEvent<{}>) => 344 toggleGroupExpanded(props.label, AnalyticsType.Detail) 345 346 // TODO (lizz): Improve the accessibility interface for accordion feature by adding focus styles 347 // according to https://www.w3.org/TR/wai-aria-practices-1.1/examples/accordion/accordion.html 348 return ( 349 <SidebarLabelSection expanded={expanded} onChange={handleChange}> 350 <SidebarGroupSummary id={labelNameId}> 351 <ResourceGroupSummaryIcon role="presentation" /> 352 <SidebarGroupName>{formattedLabel}</SidebarGroupName> 353 <SidebarGroupStatusSummary 354 labelText={`Status summary for ${props.label} group`} 355 resources={props.items} 356 /> 357 </SidebarGroupSummary> 358 <SidebarGroupDetails aria-labelledby={labelNameId}> 359 <SidebarListSection {...props} /> 360 </SidebarGroupDetails> 361 </SidebarLabelSection> 362 ) 363 } 364 365 function resourcesLabelView( 366 items: SidebarItem[] 367 ): GroupByLabelView<SidebarItem> { 368 const labelsToResources: { [key: string]: SidebarItem[] } = {} 369 const unlabeled: SidebarItem[] = [] 370 const tiltfile: SidebarItem[] = [] 371 372 items.forEach((item) => { 373 if (item.labels.length) { 374 item.labels.forEach((label) => { 375 if (!labelsToResources.hasOwnProperty(label)) { 376 labelsToResources[label] = [] 377 } 378 379 labelsToResources[label].push(item) 380 }) 381 } else if (item.isTiltfile) { 382 tiltfile.push(item) 383 } else { 384 unlabeled.push(item) 385 } 386 }) 387 388 // Labels are always displayed in sorted order 389 const labels = orderLabels(Object.keys(labelsToResources)) 390 391 return { labels, labelsToResources, tiltfile, unlabeled } 392 } 393 394 function SidebarGroupedByLabels(props: SidebarGroupedByProps) { 395 const { labels, labelsToResources, tiltfile, unlabeled } = resourcesLabelView( 396 props.items 397 ) 398 399 // NOTE(nick): We need the visual order of the items to pass 400 // to the keyboard navigation component. The problem is that 401 // each section component does its own ordering. So we cheat 402 // here and replicate the logic for determining the order. 403 let totalOrder: SidebarItem[] = [] 404 labels.map((label) => { 405 totalOrder.push(...enabledItemsFirst(labelsToResources[label])) 406 }) 407 totalOrder.push(...enabledItemsFirst(unlabeled)) 408 totalOrder.push(...enabledItemsFirst(tiltfile)) 409 410 return ( 411 <> 412 {labels.map((label) => ( 413 <SidebarGroupListSection 414 {...props} 415 key={`sidebarItem-${label}`} 416 label={label} 417 items={labelsToResources[label]} 418 /> 419 ))} 420 <SidebarGroupListSection 421 {...props} 422 label={UNLABELED_LABEL} 423 items={unlabeled} 424 /> 425 <SidebarListSection 426 {...props} 427 sectionName={TILTFILE_LABEL} 428 items={tiltfile} 429 groupView={true} 430 /> 431 <SidebarKeyboardShortcuts 432 selected={props.selected} 433 items={totalOrder} 434 onStartBuild={props.onStartBuild} 435 resourceView={props.resourceView} 436 /> 437 </> 438 ) 439 } 440 441 function hasAlerts(item: SidebarItem): boolean { 442 return item.buildAlertCount > 0 || item.runtimeAlertCount > 0 443 } 444 445 function sortByHasAlerts(itemA: SidebarItem, itemB: SidebarItem): number { 446 return Number(hasAlerts(itemB)) - Number(hasAlerts(itemA)) 447 } 448 449 function applyOptionsToItems( 450 items: SidebarItem[], 451 options: ResourceListOptions 452 ): SidebarItem[] { 453 let itemsToDisplay: SidebarItem[] = [...items] 454 455 const itemsShouldBeFiltered = 456 options.resourceNameFilter.length > 0 || !options.showDisabledResources 457 458 if (itemsShouldBeFiltered) { 459 itemsToDisplay = itemsToDisplay.filter((item) => { 460 const itemIsDisabled = item.runtimeStatus === ResourceStatus.Disabled 461 if (!options.showDisabledResources && itemIsDisabled) { 462 return false 463 } 464 465 if (options.resourceNameFilter) { 466 return matchesResourceName(item.name, options.resourceNameFilter) 467 } 468 469 return true 470 }) 471 } 472 473 if (options.alertsOnTop) { 474 itemsToDisplay.sort(sortByHasAlerts) 475 } 476 477 return itemsToDisplay 478 } 479 480 export class SidebarResources extends React.Component<SidebarProps> { 481 constructor(props: SidebarProps) { 482 super(props) 483 this.startBuildOnSelected = this.startBuildOnSelected.bind(this) 484 } 485 486 static contextType = FeaturesContext 487 488 startBuildOnSelected() { 489 if (this.props.selected) { 490 startBuild(this.props.selected) 491 } 492 } 493 494 render() { 495 const filteredItems = applyOptionsToItems( 496 this.props.items, 497 this.props.resourceListOptions 498 ) 499 500 // only say no matches if there were actually items that got filtered out 501 // otherwise, there might just be 0 resources because there are 0 resources 502 // (though technically there's probably always at least a Tiltfile resource) 503 const resourceFilterApplied = 504 this.props.resourceListOptions.resourceNameFilter.length > 0 505 const sidebarName = resourceFilterApplied 506 ? `${filteredItems.length} result${filteredItems.length === 1 ? "" : "s"}` 507 : "resources" 508 509 let isOverviewClass = 510 this.props.resourceView === ResourceView.OverviewDetail 511 ? "isOverview" 512 : "" 513 514 const labelsEnabled: boolean = this.context.isEnabled(Flag.Labels) 515 const resourcesHaveLabels = this.props.items.some( 516 (item) => item.labels.length > 0 517 ) 518 519 // The label group tip is only displayed if labels are enabled but not used 520 const displayLabelGroupsTip = labelsEnabled && !resourcesHaveLabels 521 // The label group view does not display if a resource name filter is applied 522 const displayLabelGroups = 523 !resourceFilterApplied && labelsEnabled && resourcesHaveLabels 524 525 return ( 526 <SidebarResourcesRoot 527 aria-label="Resource logs" 528 className={`Sidebar-resources ${isOverviewClass}`} 529 > 530 {displayLabelGroupsTip && ( 531 <ResourceGroupsInfoTip idForIcon={GROUP_INFO_TOOLTIP_ID} /> 532 )} 533 <SidebarResourcesContent 534 aria-describedby={ 535 displayLabelGroupsTip ? GROUP_INFO_TOOLTIP_ID : undefined 536 } 537 > 538 <OverviewSidebarOptions items={filteredItems} /> 539 <AllResourcesLink 540 pathBuilder={this.props.pathBuilder} 541 selected={this.props.selected} 542 /> 543 <StarredResourcesLink 544 pathBuilder={this.props.pathBuilder} 545 selected={this.props.selected} 546 /> 547 {displayLabelGroups ? ( 548 <SidebarGroupedByLabels 549 {...this.props} 550 items={filteredItems} 551 onStartBuild={this.startBuildOnSelected} 552 /> 553 ) : ( 554 <SidebarListSection 555 {...this.props} 556 sectionName={sidebarName} 557 items={filteredItems} 558 /> 559 )} 560 </SidebarResourcesContent> 561 {/* The label groups display handles the keyboard shortcuts separately. */} 562 {displayLabelGroups ? null : ( 563 <SidebarKeyboardShortcuts 564 selected={this.props.selected} 565 items={enabledItemsFirst(filteredItems)} 566 onStartBuild={this.startBuildOnSelected} 567 resourceView={this.props.resourceView} 568 /> 569 )} 570 </SidebarResourcesRoot> 571 ) 572 } 573 } 574 575 export default SidebarResources