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  ]