github.com/tilt-dev/tilt@v0.36.0/web/src/OverviewTableColumns.tsx (about) 1 import React, { ChangeEvent, useCallback, useMemo, useState } from "react" 2 import { CellProps, Column, HeaderProps, Row } from "react-table" 3 import TimeAgo from "react-timeago" 4 import styled from "styled-components" 5 import { ApiButton, ApiIcon, ButtonSet } from "./ApiButton" 6 import { ReactComponent as CheckmarkSvg } from "./assets/svg/checkmark.svg" 7 import { ReactComponent as CopySvg } from "./assets/svg/copy.svg" 8 import { ReactComponent as LinkSvg } from "./assets/svg/link.svg" 9 import { ReactComponent as StarSvg } from "./assets/svg/star.svg" 10 import { linkToTiltDocs, TiltDocsPage } from "./constants" 11 import { Hold } from "./Hold" 12 import { 13 InstrumentedButton, 14 InstrumentedCheckbox, 15 } from "./instrumentedComponents" 16 import { displayURL, resolveURL } from "./links" 17 import { OverviewButtonMixin } from "./OverviewButton" 18 import { OverviewTableBuildButton } from "./OverviewTableBuildButton" 19 import OverviewTableStarResourceButton from "./OverviewTableStarResourceButton" 20 import OverviewTableStatus from "./OverviewTableStatus" 21 import OverviewTableTriggerModeToggle from "./OverviewTableTriggerModeToggle" 22 import { useResourceNav } from "./ResourceNav" 23 import { useResourceSelection } from "./ResourceSelectionContext" 24 import { disabledResourceStyleMixin } from "./ResourceStatus" 25 import { useStarredResources } from "./StarredResourcesContext" 26 import { 27 Color, 28 FontSize, 29 mixinResetButtonStyle, 30 SizeUnit, 31 } from "./style-helpers" 32 import { timeAgoFormatter } from "./timeFormatters" 33 import TiltTooltip, { TiltInfoTooltip } from "./Tooltip" 34 import { startBuild } from "./trigger" 35 import { ResourceStatus, TriggerMode, UIButton, UILink } from "./types" 36 37 /** 38 * Types 39 */ 40 type OverviewTableBuildButtonStatus = { 41 isBuilding: boolean 42 hasBuilt: boolean 43 hasPendingChanges: boolean 44 isQueued: boolean 45 } 46 47 type OverviewTableResourceStatus = { 48 buildStatus: ResourceStatus 49 buildAlertCount: number 50 lastBuildDur: moment.Duration | null 51 runtimeStatus: ResourceStatus 52 runtimeAlertCount: number 53 hold?: Hold | null 54 } 55 56 export type RowValues = { 57 lastDeployTime: string 58 trigger: OverviewTableBuildButtonStatus 59 name: string 60 resourceTypeLabel: string 61 statusLine: OverviewTableResourceStatus 62 podId: string 63 endpoints: UILink[] 64 mode: TriggerMode 65 buttons: ButtonSet 66 selectable: boolean 67 } 68 69 /** 70 * Styles 71 */ 72 73 export const SelectionCheckbox = styled(InstrumentedCheckbox)` 74 &.MuiCheckbox-root, 75 &.Mui-checked { 76 color: ${Color.gray60}; 77 } 78 79 &.Mui-disabled { 80 opacity: 0.25; 81 cursor: not-allowed; 82 } 83 ` 84 85 const TableHeaderStarIcon = styled(StarSvg)` 86 fill: ${Color.gray70}; 87 height: 13px; 88 width: 13px; 89 ` 90 91 export const Name = styled.button` 92 ${mixinResetButtonStyle}; 93 color: ${Color.offWhite}; 94 font-size: ${FontSize.small}; 95 padding-top: ${SizeUnit(1 / 3)}; 96 padding-bottom: ${SizeUnit(1 / 3)}; 97 text-align: left; 98 cursor: pointer; 99 100 &:hover { 101 text-decoration: underline; 102 text-underline-position: under; 103 } 104 105 &.has-error { 106 color: ${Color.red}; 107 } 108 109 &.isDisabled { 110 ${disabledResourceStyleMixin} 111 color: ${Color.gray60}; 112 } 113 ` 114 115 const Endpoint = styled.a` 116 display: flex; 117 align-items: center; 118 max-width: 150px; 119 ` 120 const DetailText = styled.div` 121 overflow: hidden; 122 text-overflow: ellipsis; 123 white-space: nowrap; 124 ` 125 126 const StyledLinkSvg = styled(LinkSvg)` 127 fill: ${Color.gray50}; 128 flex-shrink: 0; 129 margin-right: ${SizeUnit(0.2)}; 130 ` 131 132 const PodId = styled.div` 133 display: flex; 134 align-items: center; 135 ` 136 const PodIdInput = styled.input` 137 background-color: transparent; 138 color: ${Color.gray60}; 139 font-family: inherit; 140 font-size: inherit; 141 border: 1px solid ${Color.gray10}; 142 border-radius: 2px; 143 padding: ${SizeUnit(0.1)} ${SizeUnit(0.2)}; 144 width: 100px; 145 text-overflow: ellipsis; 146 overflow: auto; 147 148 &::selection { 149 background-color: ${Color.gray30}; 150 } 151 ` 152 const PodIdCopy = styled(InstrumentedButton)` 153 ${mixinResetButtonStyle}; 154 padding-top: ${SizeUnit(0.5)}; 155 padding: ${SizeUnit(0.25)}; 156 flex-shrink: 0; 157 158 svg { 159 fill: ${Color.gray60}; 160 } 161 ` 162 const CustomActionButton = styled(ApiButton)` 163 button { 164 ${OverviewButtonMixin}; 165 } 166 ` 167 const WidgetCell = styled.span` 168 display: flex; 169 flex-wrap: wrap; 170 max-width: ${SizeUnit(8)}; 171 172 .MuiButtonGroup-root { 173 margin-bottom: ${SizeUnit(0.125)}; 174 margin-right: ${SizeUnit(0.125)}; 175 margin-top: ${SizeUnit(0.125)}; 176 } 177 ` 178 179 /** 180 * Table data helpers 181 */ 182 183 export function rowIsDisabled(row: Row<RowValues>): boolean { 184 // If a resource is disabled, both runtime and build statuses should 185 // be `disabled` and it won't matter which one we look at 186 return row.original.statusLine.runtimeStatus === ResourceStatus.Disabled 187 } 188 189 async function copyTextToClipboard(text: string, cb: () => void) { 190 await navigator.clipboard.writeText(text) 191 cb() 192 } 193 194 function statusSortKey(row: RowValues): string { 195 const status = row.statusLine 196 let order 197 if ( 198 status.buildStatus == ResourceStatus.Unhealthy || 199 status.runtimeStatus === ResourceStatus.Unhealthy 200 ) { 201 order = 0 202 } else if (status.buildAlertCount || status.runtimeAlertCount) { 203 order = 1 204 } else if ( 205 status.runtimeStatus === ResourceStatus.Disabled || 206 status.buildStatus === ResourceStatus.Disabled 207 ) { 208 // Disabled resources should appear last 209 order = 3 210 } else { 211 order = 2 212 } 213 // add name after order just to keep things stable when orders are equal 214 return `${order}${row.name}` 215 } 216 217 /** 218 * Header components 219 */ 220 export function ResourceSelectionHeader({ 221 rows, 222 column, 223 }: HeaderProps<RowValues>) { 224 const { selected, isSelected, select, deselect } = useResourceSelection() 225 226 const selectableResourcesInTable = useMemo(() => { 227 const resources: string[] = [] 228 rows.forEach(({ original }) => { 229 if (original.selectable) { 230 resources.push(original.name) 231 } 232 }) 233 234 return resources 235 }, [rows]) 236 237 function getSelectionState(resourcesInTable: string[]): { 238 indeterminate: boolean 239 checked: boolean 240 } { 241 let anySelected = false 242 let anyUnselected = false 243 for (let i = 0; i < resourcesInTable.length; i++) { 244 if (isSelected(resourcesInTable[i])) { 245 anySelected = true 246 } else { 247 anyUnselected = true 248 } 249 250 if (anySelected && anyUnselected) { 251 break 252 } 253 } 254 255 return { 256 indeterminate: anySelected && anyUnselected, 257 checked: !anyUnselected, 258 } 259 } 260 261 const { indeterminate, checked } = useMemo( 262 () => getSelectionState(selectableResourcesInTable), 263 [selectableResourcesInTable, selected] 264 ) 265 266 // If no resources in the table are selectable, don't render 267 if (selectableResourcesInTable.length === 0) { 268 return null 269 } 270 271 const onChange = (_e: ChangeEvent<HTMLInputElement>) => { 272 if (!checked) { 273 select(...selectableResourcesInTable) 274 } else { 275 deselect(...selectableResourcesInTable) 276 } 277 } 278 279 return ( 280 <SelectionCheckbox 281 aria-label="Resource group selection" 282 checked={checked} 283 aria-checked={checked} 284 indeterminate={indeterminate} 285 onChange={onChange} 286 size="small" 287 /> 288 ) 289 } 290 291 /** 292 * Column components 293 */ 294 export function TableStarColumn({ row }: CellProps<RowValues>) { 295 let ctx = useStarredResources() 296 return ( 297 <OverviewTableStarResourceButton resourceName={row.values.name} ctx={ctx} /> 298 ) 299 } 300 301 export function TableUpdateColumn({ row }: CellProps<RowValues>) { 302 if (!row.values.lastDeployTime) { 303 return null 304 } 305 return ( 306 <TimeAgo date={row.values.lastDeployTime} formatter={timeAgoFormatter} /> 307 ) 308 } 309 310 export function TableSelectionColumn({ row }: CellProps<RowValues>) { 311 const selections = useResourceSelection() 312 const resourceName = row.original.name 313 const checked = selections.isSelected(resourceName) 314 315 const onChange = useCallback( 316 (_e: ChangeEvent<HTMLInputElement>) => { 317 if (!checked) { 318 selections.select(resourceName) 319 } else { 320 selections.deselect(resourceName) 321 } 322 }, 323 [checked, selections] 324 ) 325 326 let disabled = !row.original.selectable 327 let label = row.original.selectable 328 ? "Select resource" 329 : "Cannot select resource" 330 331 return ( 332 <SelectionCheckbox 333 checked={checked} 334 aria-checked={checked} 335 onChange={onChange} 336 size="small" 337 disabled={disabled} 338 aria-label={label} 339 /> 340 ) 341 } 342 343 let TableBuildButtonColumnRoot = styled.div` 344 display: flex; 345 align-items: center; 346 ` 347 348 export function TableBuildButtonColumn({ row }: CellProps<RowValues>) { 349 // If resource is disabled, don't display build button 350 if (rowIsDisabled(row)) { 351 return null 352 } 353 354 const trigger = row.original.trigger 355 let onStartBuild = useCallback( 356 () => startBuild(row.values.name), 357 [row.values.name] 358 ) 359 return ( 360 <TableBuildButtonColumnRoot> 361 <OverviewTableBuildButton 362 hasPendingChanges={trigger.hasPendingChanges} 363 hasBuilt={trigger.hasBuilt} 364 isBuilding={trigger.isBuilding} 365 triggerMode={row.values.mode} 366 isQueued={trigger.isQueued} 367 onStartBuild={onStartBuild} 368 stopBuildButton={row.original.buttons.stopBuild} 369 /> 370 </TableBuildButtonColumnRoot> 371 ) 372 } 373 374 export function TableNameColumn({ row }: CellProps<RowValues>) { 375 let nav = useResourceNav() 376 let hasError = 377 row.original.statusLine.buildStatus === ResourceStatus.Unhealthy || 378 row.original.statusLine.runtimeStatus === ResourceStatus.Unhealthy 379 const errorClass = hasError ? "has-error" : "" 380 const disabledClass = rowIsDisabled(row) ? "isDisabled" : "" 381 return ( 382 <Name 383 className={`${errorClass} ${disabledClass}`} 384 onClick={(e) => nav.openResource(row.values.name)} 385 > 386 {row.values.name} 387 </Name> 388 ) 389 } 390 391 let TableStatusColumnRoot = styled.div` 392 display: flex; 393 flex-direction: column; 394 align-items: start; 395 justify-content: space-around; 396 min-height: 4em; 397 ` 398 399 export function TableStatusColumn({ row }: CellProps<RowValues>) { 400 const status = row.original.statusLine 401 const runtimeStatus = ( 402 <OverviewTableStatus 403 status={status.runtimeStatus} 404 resourceName={row.values.name} 405 /> 406 ) 407 408 // If a resource is disabled, only one status needs to be displayed 409 if (rowIsDisabled(row)) { 410 return <TableStatusColumnRoot>{runtimeStatus}</TableStatusColumnRoot> 411 } 412 413 return ( 414 <TableStatusColumnRoot> 415 <OverviewTableStatus 416 status={status.buildStatus} 417 lastBuildDur={status.lastBuildDur} 418 isBuild={true} 419 resourceName={row.values.name} 420 hold={status.hold} 421 /> 422 {runtimeStatus} 423 </TableStatusColumnRoot> 424 ) 425 } 426 427 export function TablePodIDColumn({ row }: CellProps<RowValues>) { 428 let [showCopySuccess, setShowCopySuccess] = useState(false) 429 430 let copyClick = () => { 431 copyTextToClipboard(row.values.podId, () => { 432 setShowCopySuccess(true) 433 434 setTimeout(() => { 435 setShowCopySuccess(false) 436 }, 3000) 437 }) 438 } 439 440 // If resource is disabled, don't display pod information 441 if (rowIsDisabled(row)) { 442 return null 443 } 444 445 let icon = showCopySuccess ? ( 446 <CheckmarkSvg width="15" height="15" /> 447 ) : ( 448 <CopySvg width="15" height="15" /> 449 ) 450 451 function selectPodIdInput() { 452 const input = document.getElementById( 453 `pod-${row.values.podId}` 454 ) as HTMLInputElement 455 input && input.select() 456 } 457 458 if (!row.values.podId) return null 459 return ( 460 <PodId> 461 <PodIdInput 462 id={`pod-${row.values.podId}`} 463 value={row.values.podId} 464 readOnly={true} 465 onClick={() => selectPodIdInput()} 466 /> 467 <PodIdCopy onClick={copyClick} title="Copy Pod ID"> 468 {icon} 469 </PodIdCopy> 470 </PodId> 471 ) 472 } 473 474 export function TableEndpointColumn({ row }: CellProps<RowValues>) { 475 // If a resource is disabled, don't display any endpoints 476 if (rowIsDisabled(row)) { 477 return null 478 } 479 480 let endpoints = row.original.endpoints.map((ep: any) => { 481 let url = resolveURL(ep.url || "") 482 return ( 483 <Endpoint 484 href={url} 485 // We use ep.url as the target, so that clicking the link re-uses the tab. 486 target={url} 487 key={url} 488 > 489 <StyledLinkSvg /> 490 <DetailText title={ep.name || displayURL(url)}> 491 {ep.name || displayURL(url)} 492 </DetailText> 493 </Endpoint> 494 ) 495 }) 496 return <>{endpoints}</> 497 } 498 499 export function TableTriggerModeColumn({ row }: CellProps<RowValues>) { 500 let isTiltfile = row.values.name == "(Tiltfile)" 501 const isDisabled = rowIsDisabled(row) 502 503 if (isTiltfile || isDisabled) return null 504 return ( 505 <OverviewTableTriggerModeToggle 506 resourceName={row.values.name} 507 triggerMode={row.values.mode} 508 /> 509 ) 510 } 511 512 export function TableWidgetsColumn({ row }: CellProps<RowValues>) { 513 // If a resource is disabled, don't display any buttons 514 if (rowIsDisabled(row)) { 515 return null 516 } 517 518 const buttons = row.original.buttons.default.map((b: UIButton) => { 519 let content = ( 520 <CustomActionButton key={b.metadata?.name} uiButton={b}> 521 <ApiIcon 522 iconName={b.spec?.iconName || "smart_button"} 523 iconSVG={b.spec?.iconSVG} 524 /> 525 </CustomActionButton> 526 ) 527 528 if (b.spec?.text) { 529 content = ( 530 <TiltTooltip title={b.spec.text}> 531 <span>{content}</span> 532 </TiltTooltip> 533 ) 534 } 535 536 return ( 537 <React.Fragment key={b.metadata?.name || ""}>{content}</React.Fragment> 538 ) 539 }) 540 return <WidgetCell>{buttons}</WidgetCell> 541 } 542 543 /** 544 * Column tooltips 545 */ 546 const modeColumn: Column<RowValues> = { 547 Header: "Mode", 548 id: "mode", 549 accessor: "mode", 550 Cell: TableTriggerModeColumn, 551 width: "auto", 552 } 553 554 const widgetsColumn: Column<RowValues> = { 555 Header: "Widgets", 556 id: "widgets", 557 accessor: (row: any) => row.buttons.default.length, 558 Cell: TableWidgetsColumn, 559 width: "auto", 560 } 561 562 const columnNameToInfoTooltip: { 563 [key: string]: NonNullable<React.ReactNode> 564 } = { 565 [modeColumn.id as string]: ( 566 <> 567 Trigger mode can be toggled through the UI. To set it persistently, see{" "} 568 <a 569 href={linkToTiltDocs(TiltDocsPage.TriggerMode)} 570 target="_blank" 571 rel="noopener noreferrer" 572 > 573 Tiltfile docs 574 </a> 575 . 576 </> 577 ), 578 [widgetsColumn.id as string]: ( 579 <> 580 Buttons can be added to resources to easily perform custom actions. See{" "} 581 <a 582 href={linkToTiltDocs(TiltDocsPage.CustomButtons)} 583 target="_blank" 584 rel="noopener noreferrer" 585 > 586 buttons docs 587 </a> 588 . 589 </> 590 ), 591 } 592 593 export function ResourceTableHeaderTip(props: { id?: string }) { 594 if (!props.id) { 595 return null 596 } 597 598 const tooltipContent = columnNameToInfoTooltip[props.id] 599 if (!tooltipContent) { 600 return null 601 } 602 603 return ( 604 <TiltInfoTooltip 605 title={tooltipContent} 606 dismissId={`table-header-${props.id}`} 607 /> 608 ) 609 } 610 611 // https://react-table.tanstack.com/docs/api/useTable#column-options 612 // The docs on this are not very clear! 613 // `accessor` should return a primitive, and that primitive is used for sorting and filtering 614 // the Cell function can get whatever it needs to render via row.original 615 // best evidence I've (Matt) found: https://github.com/tannerlinsley/react-table/discussions/2429#discussioncomment-25582 616 // (from the author) 617 export const COLUMNS: Column<RowValues>[] = [ 618 { 619 Header: (props) => <ResourceSelectionHeader {...props} />, 620 id: "selection", 621 disableSortBy: true, 622 width: "70px", 623 Cell: TableSelectionColumn, 624 }, 625 { 626 Header: () => <TableHeaderStarIcon title="Starred" />, 627 id: "starred", 628 disableSortBy: true, 629 width: "40px", 630 Cell: TableStarColumn, 631 }, 632 { 633 Header: "Updated", 634 accessor: "lastDeployTime", 635 width: "100px", 636 Cell: TableUpdateColumn, 637 }, 638 { 639 Header: "Trigger", 640 accessor: "trigger", 641 disableSortBy: true, 642 Cell: TableBuildButtonColumn, 643 width: "80px", 644 }, 645 { 646 Header: "Resource Name", 647 accessor: "name", 648 Cell: TableNameColumn, 649 width: "400px", 650 }, 651 { 652 Header: "Type", 653 accessor: "resourceTypeLabel", 654 width: "auto", 655 }, 656 { 657 Header: "Status", 658 accessor: (row) => statusSortKey(row), 659 Cell: TableStatusColumn, 660 width: "auto", 661 }, 662 { 663 Header: "Pod ID", 664 accessor: "podId", 665 width: "auto", 666 Cell: TablePodIDColumn, 667 }, 668 widgetsColumn, 669 { 670 Header: "Endpoints", 671 id: "endpoints", 672 accessor: (row) => row.endpoints.length, 673 sortType: "basic", 674 Cell: TableEndpointColumn, 675 width: "auto", 676 }, 677 modeColumn, 678 ]