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