github.com/tilt-dev/tilt@v0.36.0/web/src/OverviewTable.tsx (about)

     1  import {
     2    Accordion,
     3    AccordionDetails,
     4    AccordionSummary,
     5  } from "@material-ui/core"
     6  import React, {
     7    ChangeEvent,
     8    MouseEvent,
     9    MutableRefObject,
    10    useEffect,
    11    useMemo,
    12    useRef,
    13    useState,
    14  } from "react"
    15  import {
    16    HeaderGroup,
    17    Row,
    18    SortingRule,
    19    TableHeaderProps,
    20    TableOptions,
    21    TableState,
    22    usePagination,
    23    useSortBy,
    24    UseSortByState,
    25    useTable,
    26  } from "react-table"
    27  import styled from "styled-components"
    28  import { buildAlerts, runtimeAlerts } from "./alerts"
    29  import { ApiButtonType, buttonsForComponent } from "./ApiButton"
    30  import {
    31    DEFAULT_RESOURCE_LIST_LIMIT,
    32    RESOURCE_LIST_MULTIPLIER,
    33  } from "./constants"
    34  import Features, { Flag, useFeatures } from "./feature"
    35  import { Hold } from "./Hold"
    36  import {
    37    getResourceLabels,
    38    GroupByLabelView,
    39    orderLabels,
    40    TILTFILE_LABEL,
    41    UNLABELED_LABEL,
    42  } from "./labels"
    43  import { LogAlertIndex, useLogAlertIndex } from "./LogStore"
    44  import {
    45    COLUMNS,
    46    ResourceTableHeaderTip,
    47    rowIsDisabled,
    48    RowValues,
    49  } from "./OverviewTableColumns"
    50  import { OverviewTableKeyboardShortcuts } from "./OverviewTableKeyboardShortcuts"
    51  import {
    52    AccordionDetailsStyleResetMixin,
    53    AccordionStyleResetMixin,
    54    AccordionSummaryStyleResetMixin,
    55    ResourceGroupsInfoTip,
    56    ResourceGroupSummaryIcon,
    57    ResourceGroupSummaryMixin,
    58  } from "./ResourceGroups"
    59  import { useResourceGroups } from "./ResourceGroupsContext"
    60  import {
    61    ResourceListOptions,
    62    useResourceListOptions,
    63  } from "./ResourceListOptionsContext"
    64  import { matchesResourceName } from "./ResourceNameFilter"
    65  import { useResourceSelection } from "./ResourceSelectionContext"
    66  import { resourceIsDisabled, resourceTargetType } from "./ResourceStatus"
    67  import { TableGroupStatusSummary } from "./ResourceStatusSummary"
    68  import { ShowMoreButton } from "./ShowMoreButton"
    69  import { buildStatus, runtimeStatus } from "./status"
    70  import { Color, Font, FontSize, SizeUnit } from "./style-helpers"
    71  import { isZeroTime, timeDiff } from "./time"
    72  import {
    73    ResourceName,
    74    ResourceStatus,
    75    TargetType,
    76    TriggerMode,
    77    UIButton,
    78    UIResource,
    79    UIResourceStatus,
    80  } from "./types"
    81  
    82  export type OverviewTableProps = {
    83    view: Proto.webviewView
    84  }
    85  
    86  type TableWrapperProps = {
    87    resources?: UIResource[]
    88    buttons?: UIButton[]
    89  }
    90  
    91  type TableGroupProps = {
    92    label: string
    93    setGlobalSortBy: (id: string) => void
    94    focused: string
    95  } & TableOptions<RowValues>
    96  
    97  type TableProps = {
    98    setGlobalSortBy?: (id: string) => void
    99    focused: string
   100  } & TableOptions<RowValues>
   101  
   102  type ResourceTableHeadRowProps = {
   103    headerGroup: HeaderGroup<RowValues>
   104    setGlobalSortBy?: (id: string) => void
   105  } & TableHeaderProps
   106  
   107  // Resource name filter styles
   108  export const ResourceResultCount = styled.p`
   109    color: ${Color.gray50};
   110    font-size: ${FontSize.small};
   111    margin-top: ${SizeUnit(0.5)};
   112    margin-left: ${SizeUnit(0.5)};
   113    text-transform: uppercase;
   114  `
   115  
   116  export const NoMatchesFound = styled.p`
   117    color: ${Color.grayLightest};
   118    margin-left: ${SizeUnit(0.5)};
   119    margin-top: ${SizeUnit(1 / 4)};
   120  `
   121  
   122  // Table styles
   123  const OverviewTableRoot = styled.section`
   124    padding-bottom: ${SizeUnit(1 / 2)};
   125    margin-left: auto;
   126    margin-right: auto;
   127    /* Max and min width are based on fixed table layout and column widths */
   128    max-width: 2000px;
   129    min-width: 1400px;
   130  
   131    @media screen and (max-width: 2200px) {
   132      margin-left: ${SizeUnit(1 / 2)};
   133      margin-right: ${SizeUnit(1 / 2)};
   134    }
   135  `
   136  
   137  const TableWithoutGroupsRoot = styled.div`
   138    box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25);
   139    border: 1px ${Color.gray40} solid;
   140    border-radius: 0px 0px 8px 8px;
   141    background-color: ${Color.gray20};
   142  `
   143  
   144  const ResourceTable = styled.table`
   145    table-layout: fixed;
   146    width: 100%;
   147    border-spacing: 0;
   148    border-collapse: collapse;
   149  
   150    td {
   151      padding-left: 10px;
   152      padding-right: 10px;
   153    }
   154  
   155    td:first-child {
   156      padding-left: 24px;
   157    }
   158  
   159    td:last-child {
   160      padding-right: ${SizeUnit(1)};
   161    }
   162  `
   163  const ResourceTableHead = styled.thead`
   164    & > tr {
   165      background-color: ${Color.gray10};
   166    }
   167  `
   168  
   169  export const ResourceTableRow = styled.tr`
   170    border-top: 1px solid ${Color.gray40};
   171    font-family: ${Font.monospace};
   172    font-size: ${FontSize.small};
   173    font-style: none;
   174    color: ${Color.gray60};
   175    padding-top: 6px;
   176    padding-bottom: 6px;
   177    padding-left: 4px;
   178  
   179    &.isFocused,
   180    &:focus {
   181      border-left: 4px solid ${Color.blue};
   182      outline: none;
   183  
   184      td:first-child {
   185        padding-left: 22px;
   186      }
   187    }
   188  
   189    &.isSelected {
   190      background-color: ${Color.gray30};
   191    }
   192  
   193    /* For visual consistency on rows */
   194    &.isFixedHeight {
   195      height: ${SizeUnit(1.4)};
   196    }
   197  `
   198  export const ResourceTableData = styled.td`
   199    box-sizing: border-box;
   200  
   201    &.isSorted {
   202      background-color: ${Color.gray30};
   203    }
   204  
   205    &.alignRight {
   206      text-align: right;
   207    }
   208  `
   209  
   210  export const ResourceTableHeader = styled(ResourceTableData)`
   211    color: ${Color.gray70};
   212    font-size: ${FontSize.small};
   213    box-sizing: border-box;
   214    white-space: nowrap;
   215  
   216    &.isSorted {
   217      background-color: ${Color.gray20};
   218    }
   219  `
   220  
   221  const ResourceTableHeaderLabel = styled.div`
   222    display: flex;
   223    align-items: center;
   224    user-select: none;
   225  `
   226  
   227  export const ResourceTableHeaderSortTriangle = styled.div`
   228    display: inline-block;
   229    margin-left: ${SizeUnit(0.25)};
   230    width: 0;
   231    height: 0;
   232    border-left: 5px solid transparent;
   233    border-right: 5px solid transparent;
   234    border-bottom: 6px solid ${Color.gray50};
   235  
   236    &.is-sorted-asc {
   237      border-bottom: 6px solid ${Color.blue};
   238    }
   239    &.is-sorted-desc {
   240      border-bottom: 6px solid ${Color.blue};
   241      transform: rotate(180deg);
   242    }
   243  `
   244  
   245  // Table Group styles
   246  export const OverviewGroup = styled(Accordion)`
   247    ${AccordionStyleResetMixin}
   248    color: ${Color.gray50};
   249    border: 1px ${Color.gray40} solid;
   250    background-color: ${Color.gray20};
   251  
   252    &.MuiAccordion-root,
   253    &.MuiAccordion-root.Mui-expanded {
   254      margin-top: ${SizeUnit(1 / 2)};
   255    }
   256  
   257    &.Mui-expanded {
   258      box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25);
   259      border-radius: 0px 0px 8px 8px;
   260    }
   261  `
   262  
   263  export const OverviewGroupSummary = styled(AccordionSummary)`
   264    ${AccordionSummaryStyleResetMixin}
   265    ${ResourceGroupSummaryMixin}
   266    background-color: ${Color.gray10};
   267  
   268    .MuiAccordionSummary-content {
   269      font-size: ${FontSize.default};
   270    }
   271  `
   272  
   273  export const OverviewGroupName = styled.span`
   274    padding: 0 ${SizeUnit(1 / 3)};
   275  `
   276  
   277  export const OverviewGroupDetails = styled(AccordionDetails)`
   278    ${AccordionDetailsStyleResetMixin}
   279  `
   280  
   281  const GROUP_INFO_TOOLTIP_ID = "table-groups-info"
   282  
   283  export function TableResourceResultCount(props: { resources?: UIResource[] }) {
   284    const { options } = useResourceListOptions()
   285  
   286    if (
   287      props.resources === undefined ||
   288      options.resourceNameFilter.length === 0
   289    ) {
   290      return null
   291    }
   292  
   293    const count = props.resources.length
   294  
   295    return (
   296      <ResourceResultCount>
   297        {count} result{count !== 1 ? "s" : ""}
   298      </ResourceResultCount>
   299    )
   300  }
   301  
   302  export function TableNoMatchesFound(props: { resources?: UIResource[] }) {
   303    const { options } = useResourceListOptions()
   304  
   305    if (props.resources?.length === 0 && options.resourceNameFilter.length > 0) {
   306      return <NoMatchesFound>No matching resources</NoMatchesFound>
   307    }
   308  
   309    return null
   310  }
   311  
   312  const FIRST_SORT_STATE = false
   313  const SECOND_SORT_STATE = true
   314  
   315  // This helper function manually implements the toggle sorting
   316  // logic used by react-table, so we can keep the sorting state
   317  // globally and sort multiple tables by the same column.
   318  //    Click once to sort by ascending values
   319  //    Click twice to sort by descending values
   320  //    Click thrice to remove sort
   321  // Note: this does NOT support sorting by multiple columns.
   322  function calculateNextSort(
   323    id: string,
   324    sortByState: SortingRule<RowValues>[] | undefined
   325  ): SortingRule<RowValues>[] {
   326    if (!sortByState || sortByState.length === 0) {
   327      return [{ id, desc: FIRST_SORT_STATE }]
   328    }
   329  
   330    // If the current sort is the same column as next sort,
   331    // determine its next value
   332    const [currentSort] = sortByState
   333    if (currentSort.id === id) {
   334      const { desc } = currentSort
   335  
   336      if (desc === undefined) {
   337        return [{ id, desc: FIRST_SORT_STATE }]
   338      }
   339  
   340      if (desc === FIRST_SORT_STATE) {
   341        return [{ id, desc: SECOND_SORT_STATE }]
   342      }
   343  
   344      if (desc === SECOND_SORT_STATE) {
   345        return []
   346      }
   347    }
   348  
   349    return [{ id, desc: FIRST_SORT_STATE }]
   350  }
   351  
   352  function applyOptionsToResources(
   353    resources: UIResource[] | undefined,
   354    options: ResourceListOptions,
   355    features: Features
   356  ): UIResource[] {
   357    if (!resources) {
   358      return []
   359    }
   360  
   361    const hideDisabledResources = !options.showDisabledResources
   362    const resourceNameFilter = options.resourceNameFilter.length > 0
   363  
   364    // If there are no options to apply to the resources, return the un-filtered, sorted list
   365    if (!resourceNameFilter && !hideDisabledResources) {
   366      return sortByDisableStatus(resources)
   367    }
   368  
   369    // Otherwise, apply the options to the resources and sort it
   370    const filteredResources = resources.filter((r) => {
   371      const resourceDisabled = resourceIsDisabled(r)
   372      if (hideDisabledResources && resourceDisabled) {
   373        return false
   374      }
   375  
   376      if (resourceNameFilter) {
   377        return matchesResourceName(
   378          r.metadata?.name || "",
   379          options.resourceNameFilter
   380        )
   381      }
   382  
   383      return true
   384    })
   385  
   386    return sortByDisableStatus(filteredResources)
   387  }
   388  
   389  function uiResourceToCell(
   390    r: UIResource,
   391    allButtons: UIButton[] | undefined,
   392    alertIndex: LogAlertIndex
   393  ): RowValues {
   394    let res = (r.status || {}) as UIResourceStatus
   395    let buildHistory = res.buildHistory || []
   396    let lastBuild = buildHistory.length > 0 ? buildHistory[0] : null
   397    let lastBuildDur =
   398      lastBuild?.startTime && lastBuild?.finishTime
   399        ? timeDiff(lastBuild.startTime, lastBuild.finishTime)
   400        : null
   401    let currentBuildStartTime = res.currentBuild?.startTime ?? ""
   402    let isBuilding = !isZeroTime(currentBuildStartTime)
   403    let hasBuilt = lastBuild !== null
   404    let buttons = buttonsForComponent(
   405      allButtons,
   406      ApiButtonType.Resource,
   407      r.metadata?.name
   408    )
   409    // Consider a resource `selectable` if it can be disabled
   410    const selectable = !!buttons.toggleDisable
   411  
   412    return {
   413      lastDeployTime: res.lastDeployTime ?? "",
   414      trigger: {
   415        isBuilding: isBuilding,
   416        hasBuilt: hasBuilt,
   417        hasPendingChanges: !!res.hasPendingChanges,
   418        isQueued: !!res.queued,
   419      },
   420      name: r.metadata?.name ?? "",
   421      resourceTypeLabel: resourceTypeLabel(r),
   422      statusLine: {
   423        buildStatus: buildStatus(r, alertIndex),
   424        buildAlertCount: buildAlerts(r, alertIndex).length,
   425        lastBuildDur: lastBuildDur,
   426        runtimeStatus: runtimeStatus(r, alertIndex),
   427        runtimeAlertCount: runtimeAlerts(r, alertIndex).length,
   428        hold: res.waiting ? new Hold(res.waiting) : null,
   429      },
   430      podId: res.k8sResourceInfo?.podName ?? "",
   431      endpoints: res.endpointLinks ?? [],
   432      mode: res.triggerMode ?? TriggerMode.TriggerModeAuto,
   433      buttons: buttons,
   434      selectable,
   435    }
   436  }
   437  
   438  function resourceTypeLabel(r: UIResource): string {
   439    let res = (r.status || {}) as UIResourceStatus
   440    let name = r.metadata?.name
   441    if (name == "(Tiltfile)") {
   442      return "Tiltfile"
   443    }
   444    let specs = res.specs ?? []
   445    for (let i = 0; i < specs.length; i++) {
   446      let spec = specs[i]
   447      if (spec.type === TargetType.K8s) {
   448        return "K8s"
   449      } else if (spec.type === TargetType.DockerCompose) {
   450        return "DCS"
   451      } else if (spec.type === TargetType.Local) {
   452        return "Local"
   453      }
   454    }
   455    return "Unknown"
   456  }
   457  
   458  function sortByDisableStatus(resources: UIResource[] = []) {
   459    // Sort by disabled status, so disabled resources appear at the end of each table list.
   460    // Note: this initial sort is done here so it doesn't interfere with the sorting
   461    // managed by react-table
   462    const sorted = [...resources].sort((a, b) => {
   463      const resourceAOrder = resourceIsDisabled(a) ? 1 : 0
   464      const resourceBOrder = resourceIsDisabled(b) ? 1 : 0
   465  
   466      return resourceAOrder - resourceBOrder
   467    })
   468  
   469    return sorted
   470  }
   471  
   472  function onlyEnabledRows(rows: RowValues[]): RowValues[] {
   473    return rows.filter(
   474      (row) => row.statusLine.runtimeStatus !== ResourceStatus.Disabled
   475    )
   476  }
   477  function onlyDisabledRows(rows: RowValues[]): RowValues[] {
   478    return rows.filter(
   479      (row) => row.statusLine.runtimeStatus === ResourceStatus.Disabled
   480    )
   481  }
   482  function enabledRowsFirst(rows: RowValues[]): RowValues[] {
   483    let result = onlyEnabledRows(rows)
   484    result.push(...onlyDisabledRows(rows))
   485    return result
   486  }
   487  
   488  export function labeledResourcesToTableCells(
   489    resources: UIResource[] | undefined,
   490    buttons: UIButton[] | undefined,
   491    logAlertIndex: LogAlertIndex
   492  ): GroupByLabelView<RowValues> {
   493    const labelsToResources: { [key: string]: RowValues[] } = {}
   494    const unlabeled: RowValues[] = []
   495    const tiltfile: RowValues[] = []
   496  
   497    if (resources === undefined) {
   498      return { labels: [], labelsToResources, tiltfile, unlabeled }
   499    }
   500  
   501    resources.forEach((r) => {
   502      const labels = getResourceLabels(r)
   503      const isTiltfile = r.metadata?.name === ResourceName.tiltfile
   504      const tableCell = uiResourceToCell(r, buttons, logAlertIndex)
   505      if (labels.length) {
   506        labels.forEach((label) => {
   507          if (!labelsToResources.hasOwnProperty(label)) {
   508            labelsToResources[label] = []
   509          }
   510  
   511          labelsToResources[label].push(tableCell)
   512        })
   513      } else if (isTiltfile) {
   514        tiltfile.push(tableCell)
   515      } else {
   516        unlabeled.push(tableCell)
   517      }
   518    })
   519  
   520    // Labels are always displayed in sorted order
   521    const labels = orderLabels(Object.keys(labelsToResources))
   522  
   523    return { labels, labelsToResources, tiltfile, unlabeled }
   524  }
   525  
   526  export function ResourceTableHeadRow({
   527    headerGroup,
   528    setGlobalSortBy,
   529  }: ResourceTableHeadRowProps) {
   530    const calculateToggleProps = (column: HeaderGroup<RowValues>) => {
   531      // If a column header is JSX, fall back on using its id as a descriptive title
   532      // and capitalize for consistency
   533      const columnHeader =
   534        typeof column.Header === "string"
   535          ? column.Header
   536          : `${column.id[0]?.toUpperCase()}${column.id?.slice(1)}`
   537  
   538      // Warning! Toggle props are not typed or documented well within react-table.
   539      // Modify toggle props with caution.
   540      // See https://react-table.tanstack.com/docs/api/useSortBy#column-properties
   541      const toggleProps: { [key: string]: any } = {
   542        title: column.canSort ? `Sort by ${columnHeader}` : columnHeader,
   543      }
   544  
   545      if (setGlobalSortBy && column.canSort) {
   546        // The sort state is global whenever there are multiple tables, so
   547        // pass a click handler to the sort toggle that changes the global state
   548        toggleProps.onClick = () => setGlobalSortBy(column.id)
   549      }
   550  
   551      return toggleProps
   552    }
   553  
   554    const calculateHeaderProps = (column: HeaderGroup<RowValues>) => {
   555      const headerProps: Partial<TableHeaderProps> = {
   556        style: { width: column.width },
   557      }
   558  
   559      if (column.isSorted) {
   560        headerProps.className = "isSorted"
   561      }
   562  
   563      return headerProps
   564    }
   565  
   566    return (
   567      <ResourceTableRow>
   568        {headerGroup.headers.map((column) => (
   569          <ResourceTableHeader
   570            {...column.getHeaderProps([
   571              calculateHeaderProps(column),
   572              column.getSortByToggleProps(calculateToggleProps(column)),
   573            ])}
   574          >
   575            <ResourceTableHeaderLabel>
   576              {column.render("Header")}
   577              <ResourceTableHeaderTip id={String(column.id)} />
   578              {column.canSort && (
   579                <ResourceTableHeaderSortTriangle
   580                  className={
   581                    column.isSorted
   582                      ? column.isSortedDesc
   583                        ? "is-sorted-desc"
   584                        : "is-sorted-asc"
   585                      : ""
   586                  }
   587                />
   588              )}
   589            </ResourceTableHeaderLabel>
   590          </ResourceTableHeader>
   591        ))}
   592      </ResourceTableRow>
   593    )
   594  }
   595  
   596  function ShowMoreResourcesRow({
   597    colSpan,
   598    itemCount,
   599    pageSize,
   600    onClick,
   601  }: {
   602    colSpan: number
   603    itemCount: number
   604    pageSize: number
   605    onClick: (e: MouseEvent) => void
   606  }) {
   607    if (itemCount <= pageSize) {
   608      return null
   609    }
   610  
   611    return (
   612      <ResourceTableRow className="isFixedHeight">
   613        <ResourceTableData colSpan={colSpan - 2} />
   614        <ResourceTableData className="alignRight" colSpan={2}>
   615          <ShowMoreButton
   616            itemCount={itemCount}
   617            currentListSize={pageSize}
   618            onClick={onClick}
   619          />
   620        </ResourceTableData>
   621      </ResourceTableRow>
   622    )
   623  }
   624  
   625  function TableRow(props: { row: Row<RowValues>; focused: string }) {
   626    let { row, focused } = props
   627    const { isSelected } = useResourceSelection()
   628    let isFocused = row.original.name == focused
   629    let rowClasses =
   630      (rowIsDisabled(row) ? "isDisabled " : "") +
   631      (isSelected(row.original.name) ? "isSelected " : "") +
   632      (isFocused ? "isFocused " : "")
   633    let ref: MutableRefObject<HTMLTableRowElement | null> = useRef(null)
   634  
   635    useEffect(() => {
   636      if (isFocused && ref.current) {
   637        ref.current.focus()
   638      }
   639    }, [isFocused, ref])
   640  
   641    return (
   642      <ResourceTableRow
   643        tabIndex={-1}
   644        ref={ref}
   645        {...row.getRowProps({
   646          className: rowClasses,
   647        })}
   648      >
   649        {row.cells.map((cell) => (
   650          <ResourceTableData
   651            {...cell.getCellProps()}
   652            className={cell.column.isSorted ? "isSorted" : ""}
   653          >
   654            {cell.render("Cell")}
   655          </ResourceTableData>
   656        ))}
   657      </ResourceTableRow>
   658    )
   659  }
   660  
   661  export function Table(props: TableProps) {
   662    if (props.data.length === 0) {
   663      return null
   664    }
   665  
   666    const {
   667      getTableProps,
   668      getTableBodyProps,
   669      headerGroups,
   670      rows, // Used to calculate the total number of rows
   671      page, // Used to render the rows for the current page
   672      prepareRow,
   673      state: { pageSize },
   674      setPageSize,
   675    } = useTable(
   676      {
   677        columns: props.columns,
   678        data: props.data,
   679        autoResetSortBy: false,
   680        useControlledState: props.useControlledState,
   681        initialState: { pageSize: DEFAULT_RESOURCE_LIST_LIMIT },
   682      },
   683      useSortBy,
   684      usePagination
   685    )
   686  
   687    const showMoreOnClick = () => setPageSize(pageSize * RESOURCE_LIST_MULTIPLIER)
   688  
   689    // TODO (lizz): Consider adding `aria-sort` markup to table headings
   690    return (
   691      <ResourceTable {...getTableProps()}>
   692        <ResourceTableHead>
   693          {headerGroups.map((headerGroup: HeaderGroup<RowValues>) => (
   694            <ResourceTableHeadRow
   695              {...headerGroup.getHeaderGroupProps()}
   696              headerGroup={headerGroup}
   697              setGlobalSortBy={props.setGlobalSortBy}
   698            />
   699          ))}
   700        </ResourceTableHead>
   701        <tbody {...getTableBodyProps()}>
   702          {page.map((row: Row<RowValues>) => {
   703            prepareRow(row)
   704            return (
   705              <TableRow
   706                key={row.original.name}
   707                row={row}
   708                focused={props.focused}
   709              />
   710            )
   711          })}
   712          <ShowMoreResourcesRow
   713            itemCount={rows.length}
   714            pageSize={pageSize}
   715            onClick={showMoreOnClick}
   716            colSpan={props.columns.length}
   717          />
   718        </tbody>
   719      </ResourceTable>
   720    )
   721  }
   722  
   723  function TableGroup(props: TableGroupProps) {
   724    const { label, ...tableProps } = props
   725  
   726    if (tableProps.data.length === 0) {
   727      return null
   728    }
   729  
   730    const formattedLabel = label === UNLABELED_LABEL ? <em>{label}</em> : label
   731    const labelNameId = `tableOverview-${label}`
   732  
   733    const { getGroup, toggleGroupExpanded } = useResourceGroups()
   734    const { expanded } = getGroup(label)
   735    const handleChange = (_e: ChangeEvent<{}>) => toggleGroupExpanded(label)
   736  
   737    return (
   738      <OverviewGroup expanded={expanded} onChange={handleChange}>
   739        <OverviewGroupSummary id={labelNameId}>
   740          <ResourceGroupSummaryIcon role="presentation" />
   741          <OverviewGroupName>{formattedLabel}</OverviewGroupName>
   742          <TableGroupStatusSummary
   743            labelText={`Status summary for ${label} group`}
   744            resources={tableProps.data}
   745          />
   746        </OverviewGroupSummary>
   747        <OverviewGroupDetails>
   748          <Table {...tableProps} />
   749        </OverviewGroupDetails>
   750      </OverviewGroup>
   751    )
   752  }
   753  
   754  export function TableGroupedByLabels({
   755    resources,
   756    buttons,
   757  }: TableWrapperProps) {
   758    const features = useFeatures()
   759    const logAlertIndex = useLogAlertIndex()
   760    const data = useMemo(
   761      () => labeledResourcesToTableCells(resources, buttons, logAlertIndex),
   762      [resources, buttons]
   763    )
   764  
   765    const totalOrder = useMemo(() => {
   766      let totalOrder = []
   767      data.labels.forEach((label) =>
   768        totalOrder.push(...enabledRowsFirst(data.labelsToResources[label]))
   769      )
   770      totalOrder.push(...enabledRowsFirst(data.unlabeled))
   771      totalOrder.push(...enabledRowsFirst(data.tiltfile))
   772      return totalOrder
   773    }, [data])
   774    let [focused, setFocused] = useState("")
   775  
   776    // Global table settings are currently used to sort multiple
   777    // tables by the same column
   778    // See: https://react-table.tanstack.com/docs/faq#how-can-i-manually-control-the-table-state
   779    const [globalTableSettings, setGlobalTableSettings] =
   780      useState<UseSortByState<RowValues>>()
   781  
   782    const useControlledState = (state: TableState<RowValues>) =>
   783      useMemo(() => {
   784        return { ...state, ...globalTableSettings }
   785      }, [state, globalTableSettings])
   786  
   787    const setGlobalSortBy = (columnId: string) => {
   788      const sortBy = calculateNextSort(columnId, globalTableSettings?.sortBy)
   789      setGlobalTableSettings({ sortBy })
   790    }
   791  
   792    return (
   793      <>
   794        {data.labels.map((label) => (
   795          <TableGroup
   796            key={label}
   797            label={label}
   798            data={data.labelsToResources[label]}
   799            columns={COLUMNS}
   800            useControlledState={useControlledState}
   801            setGlobalSortBy={setGlobalSortBy}
   802            focused={focused}
   803          />
   804        ))}
   805        <TableGroup
   806          label={UNLABELED_LABEL}
   807          data={data.unlabeled}
   808          columns={COLUMNS}
   809          useControlledState={useControlledState}
   810          setGlobalSortBy={setGlobalSortBy}
   811          focused={focused}
   812        />
   813        <TableGroup
   814          label={TILTFILE_LABEL}
   815          data={data.tiltfile}
   816          columns={COLUMNS}
   817          useControlledState={useControlledState}
   818          setGlobalSortBy={setGlobalSortBy}
   819          focused={focused}
   820        />
   821        <OverviewTableKeyboardShortcuts
   822          focused={focused}
   823          setFocused={setFocused}
   824          rows={totalOrder}
   825        />
   826      </>
   827    )
   828  }
   829  
   830  export function TableWithoutGroups({ resources, buttons }: TableWrapperProps) {
   831    const features = useFeatures()
   832    const logAlertIndex = useLogAlertIndex()
   833    const data = useMemo(() => {
   834      return (
   835        resources?.map((r) => uiResourceToCell(r, buttons, logAlertIndex)) || []
   836      )
   837    }, [resources, buttons])
   838  
   839    let totalOrder = useMemo(() => enabledRowsFirst(data), [data])
   840    let [focused, setFocused] = useState("")
   841  
   842    if (resources?.length === 0) {
   843      return null
   844    }
   845  
   846    return (
   847      <TableWithoutGroupsRoot>
   848        <Table columns={COLUMNS} data={data} focused={focused} />
   849        <OverviewTableKeyboardShortcuts
   850          focused={focused}
   851          setFocused={setFocused}
   852          rows={totalOrder}
   853        />
   854      </TableWithoutGroupsRoot>
   855    )
   856  }
   857  
   858  function OverviewTableContent(props: OverviewTableProps) {
   859    const features = useFeatures()
   860    const labelsEnabled = features.isEnabled(Flag.Labels)
   861    const resourcesHaveLabels =
   862      props.view.uiResources?.some((r) => getResourceLabels(r).length > 0) ||
   863      false
   864  
   865    const { options } = useResourceListOptions()
   866    const resourceFilterApplied = options.resourceNameFilter.length > 0
   867  
   868    // Apply any display filters or options to resources, plus sort for initial view
   869    const resourcesToDisplay = applyOptionsToResources(
   870      props.view.uiResources,
   871      options,
   872      features
   873    )
   874  
   875    // Table groups are displayed when feature is enabled, resources have labels,
   876    // and no resource name filter is applied
   877    const displayResourceGroups =
   878      labelsEnabled && resourcesHaveLabels && !resourceFilterApplied
   879  
   880    if (displayResourceGroups) {
   881      return (
   882        <TableGroupedByLabels
   883          resources={resourcesToDisplay}
   884          buttons={props.view.uiButtons}
   885        />
   886      )
   887    } else {
   888      // The label group tip is only displayed if labels are enabled but not used
   889      const displayLabelGroupsTip = labelsEnabled && !resourcesHaveLabels
   890  
   891      return (
   892        <>
   893          {displayLabelGroupsTip && (
   894            <ResourceGroupsInfoTip idForIcon={GROUP_INFO_TOOLTIP_ID} />
   895          )}
   896          <TableResourceResultCount resources={resourcesToDisplay} />
   897          <TableNoMatchesFound resources={resourcesToDisplay} />
   898          <TableWithoutGroups
   899            aria-describedby={
   900              displayLabelGroupsTip ? GROUP_INFO_TOOLTIP_ID : undefined
   901            }
   902            resources={resourcesToDisplay}
   903            buttons={props.view.uiButtons}
   904          />
   905        </>
   906      )
   907    }
   908  }
   909  
   910  export default function OverviewTable(props: OverviewTableProps) {
   911    return (
   912      <OverviewTableRoot aria-label="Resources overview">
   913        <OverviewTableContent {...props} />
   914      </OverviewTableRoot>
   915    )
   916  }