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