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