github.com/tilt-dev/tilt@v0.33.15-0.20240515162809-0a22ed45d8a0/web/src/OverviewTable.test.tsx (about)

     1  import { render, screen } from "@testing-library/react"
     2  import userEvent from "@testing-library/user-event"
     3  import { SnackbarProvider } from "notistack"
     4  import React, { ReactElement } from "react"
     5  import { MemoryRouter } from "react-router"
     6  import {
     7    cleanupMockAnalyticsCalls,
     8    mockAnalyticsCalls,
     9  } from "./analytics_test_helpers"
    10  import { ApiButtonRoot } from "./ApiButton"
    11  import Features, { FeaturesTestProvider, Flag } from "./feature"
    12  import { GroupByLabelView, TILTFILE_LABEL, UNLABELED_LABEL } from "./labels"
    13  import LogStore from "./LogStore"
    14  import OverviewTable, {
    15    labeledResourcesToTableCells,
    16    NoMatchesFound,
    17    OverviewGroup,
    18    OverviewGroupName,
    19    ResourceResultCount,
    20    ResourceTableData,
    21    ResourceTableHeaderSortTriangle,
    22    ResourceTableRow,
    23    TableGroupedByLabels,
    24  } from "./OverviewTable"
    25  import { Name, RowValues, SelectionCheckbox } from "./OverviewTableColumns"
    26  import { ToggleTriggerModeTooltip } from "./OverviewTableTriggerModeToggle"
    27  import {
    28    DEFAULT_GROUP_STATE,
    29    GroupsState,
    30    ResourceGroupsContextProvider,
    31  } from "./ResourceGroupsContext"
    32  import {
    33    DEFAULT_OPTIONS,
    34    ResourceListOptions,
    35    ResourceListOptionsProvider,
    36  } from "./ResourceListOptionsContext"
    37  import { matchesResourceName } from "./ResourceNameFilter"
    38  import { ResourceSelectionProvider } from "./ResourceSelectionContext"
    39  import { ResourceStatusSummaryRoot } from "./ResourceStatusSummary"
    40  import {
    41    nResourceView,
    42    nResourceWithLabelsView,
    43    oneResource,
    44    oneUIButton,
    45    TestDataView,
    46  } from "./testdata"
    47  import { RuntimeStatus, TriggerMode, UpdateStatus } from "./types"
    48  
    49  // Helpers
    50  const tableViewWithSettings = ({
    51    view,
    52    labelsEnabled,
    53    resourceListOptions,
    54    resourceSelections,
    55  }: {
    56    view: TestDataView
    57    labelsEnabled?: boolean
    58    resourceListOptions?: ResourceListOptions
    59    resourceSelections?: string[]
    60  }) => {
    61    const features = new Features({
    62      [Flag.Labels]: labelsEnabled ?? true,
    63    })
    64    return (
    65      <MemoryRouter initialEntries={["/"]}>
    66        <SnackbarProvider>
    67          <FeaturesTestProvider value={features}>
    68            <ResourceGroupsContextProvider>
    69              <ResourceListOptionsProvider
    70                initialValuesForTesting={resourceListOptions}
    71              >
    72                <ResourceSelectionProvider
    73                  initialValuesForTesting={resourceSelections}
    74                >
    75                  <OverviewTable view={view} />
    76                </ResourceSelectionProvider>
    77              </ResourceListOptionsProvider>
    78            </ResourceGroupsContextProvider>
    79          </FeaturesTestProvider>
    80        </SnackbarProvider>
    81      </MemoryRouter>
    82    )
    83  }
    84  
    85  const findTableHeaderByName = (columnName: string, sortable = true): any => {
    86    const selector = sortable ? `Sort by ${columnName}` : columnName
    87    return screen.getAllByTitle(selector)[0]
    88  }
    89  
    90  // End helpers
    91  
    92  afterEach(() => {
    93    sessionStorage.clear()
    94    localStorage.clear()
    95  })
    96  
    97  it("shows buttons on the appropriate resources", () => {
    98    let view = nResourceView(3)
    99    // one resource with one button, one with multiple, and one with none
   100    view.uiButtons = [
   101      oneUIButton({
   102        buttonName: "button1",
   103        buttonText: "text1",
   104        componentID: view.uiResources[0].metadata?.name!,
   105      }),
   106      oneUIButton({
   107        buttonName: "button2",
   108        buttonText: "text2",
   109        componentID: view.uiResources[1].metadata?.name!,
   110      }),
   111      oneUIButton({
   112        buttonName: "button3",
   113        buttonText: "text3",
   114        componentID: view.uiResources[1].metadata?.name!,
   115      }),
   116    ]
   117  
   118    const { container } = render(tableViewWithSettings({ view }))
   119  
   120    // buttons expected to be on each row, in order
   121    const expectedButtons = [["text1"], ["text2", "text3"], []]
   122    // first row is headers, so skip it
   123    const rows = Array.from(container.querySelectorAll(ResourceTableRow)).slice(1)
   124    const actualButtons = rows.map((row) =>
   125      Array.from(row.querySelectorAll(ApiButtonRoot)).map((e) =>
   126        e.getAttribute("aria-label")
   127      )
   128    )
   129  
   130    expect(actualButtons).toEqual(expectedButtons)
   131  })
   132  
   133  it("sorts by status", () => {
   134    let view = nResourceView(10)
   135    view.uiResources[3].status!.updateStatus = UpdateStatus.Error
   136    view.uiResources[7].status!.runtimeStatus = RuntimeStatus.Error
   137    view.uiResources.unshift(
   138      oneResource({ disabled: true, name: "disabled_resource" })
   139    )
   140  
   141    const { container } = render(
   142      tableViewWithSettings({
   143        view,
   144        resourceListOptions: { ...DEFAULT_OPTIONS, showDisabledResources: true },
   145      })
   146    )
   147  
   148    const statusHeader = screen.getByText("Status")
   149    userEvent.click(statusHeader)
   150  
   151    const rows = Array.from(container.querySelectorAll(ResourceTableRow)).slice(1) // skip the header
   152    const actualResources = rows.map(
   153      (row) =>
   154        row.querySelectorAll(ResourceTableData)[4].querySelector("button")!
   155          .textContent
   156    )
   157    // 3 and 7 go first because they're failing, then it's alpha,
   158    // followed by disabled resources
   159    const expectedResources = [
   160      "_3",
   161      "_7",
   162      "(Tiltfile)",
   163      "_1",
   164      "_2",
   165      "_4",
   166      "_5",
   167      "_6",
   168      "_8",
   169      "_9",
   170      "disabled_resource",
   171    ]
   172    expect(expectedResources).toEqual(actualResources)
   173  })
   174  
   175  describe("resource name filter", () => {
   176    describe("when a filter is applied", () => {
   177      let view: TestDataView
   178      let container: HTMLElement
   179  
   180      beforeEach(() => {
   181        view = nResourceView(100)
   182        container = renderContainer(
   183          tableViewWithSettings({
   184            view,
   185            resourceListOptions: {
   186              ...DEFAULT_OPTIONS,
   187              resourceNameFilter: "1",
   188            },
   189          })
   190        )
   191      })
   192  
   193      it("displays an accurate result count", () => {
   194        const resultCount = container.querySelector(ResourceResultCount)
   195        expect(resultCount).toBeDefined()
   196        // Expect 19 results because test data names resources with their index number
   197        expect(resultCount!.textContent).toMatch(/19/)
   198      })
   199  
   200      it("displays only matching resources if there are matches", () => {
   201        const matchingRows = container.querySelectorAll("table tr")
   202  
   203        // Expect 20 results because test data names resources with their index number
   204        expect(matchingRows.length).toBe(20)
   205  
   206        const displayedResourceNames = Array.from(
   207          container.querySelectorAll(Name)
   208        ).map((nameCell: any) => nameCell.textContent)
   209        const everyNameMatchesFilterTerm = displayedResourceNames.every((name) =>
   210          matchesResourceName(name, "1")
   211        )
   212  
   213        expect(everyNameMatchesFilterTerm).toBe(true)
   214      })
   215  
   216      it("displays a `no matches` message if there are no matches", () => {
   217        container = renderContainer(
   218          tableViewWithSettings({
   219            view,
   220            resourceListOptions: {
   221              ...DEFAULT_OPTIONS,
   222              resourceNameFilter: "eek no matches!",
   223            },
   224          })
   225        )
   226  
   227        expect(container.querySelector(NoMatchesFound)).toBeDefined()
   228      })
   229    })
   230  
   231    describe("when a filter is NOT applied", () => {
   232      it("displays all resources", () => {
   233        const { container } = render(
   234          tableViewWithSettings({
   235            view: nResourceView(10),
   236            resourceListOptions: { ...DEFAULT_OPTIONS },
   237          })
   238        )
   239  
   240        expect(
   241          container.querySelectorAll(`tbody ${ResourceTableRow}`).length
   242        ).toBe(10)
   243      })
   244    })
   245  })
   246  
   247  describe("when labels feature is enabled", () => {
   248    it("it displays tables grouped by labels if resources have labels", () => {
   249      const { container } = render(
   250        tableViewWithSettings({
   251          view: nResourceWithLabelsView(5),
   252          labelsEnabled: true,
   253        })
   254      )
   255  
   256      let labels = Array.from(container.querySelectorAll(OverviewGroupName)).map(
   257        (n) => n.textContent
   258      )
   259      expect(labels).toEqual([
   260        "backend",
   261        "frontend",
   262        "javascript",
   263        "test",
   264        "very_long_long_long_label",
   265        "unlabeled",
   266        "Tiltfile",
   267      ])
   268    })
   269  
   270    it("it displays a single table if no resources have labels", () => {
   271      const { container } = render(
   272        tableViewWithSettings({ view: nResourceView(5), labelsEnabled: true })
   273      )
   274  
   275      let labels = Array.from(container.querySelectorAll(OverviewGroupName)).map(
   276        (n) => n.textContent
   277      )
   278      expect(labels).toEqual([])
   279    })
   280  
   281    it("it displays the resource grouping tooltip if no resources have labels", () => {
   282      const { container } = render(
   283        tableViewWithSettings({ view: nResourceView(5), labelsEnabled: true })
   284      )
   285  
   286      expect(
   287        container.querySelectorAll('#table-groups-info[role="tooltip"]').length
   288      ).toBe(1)
   289    })
   290  })
   291  
   292  describe("when labels feature is NOT enabled", () => {
   293    let container: HTMLElement
   294  
   295    beforeEach(() => {
   296      container = renderContainer(
   297        tableViewWithSettings({
   298          view: nResourceWithLabelsView(5),
   299          labelsEnabled: false,
   300        })
   301      )
   302    })
   303  
   304    it("it displays a single table", () => {
   305      expect(container.querySelectorAll("table").length).toBe(1)
   306    })
   307  
   308    it("it does not display the resource grouping tooltip", () => {
   309      expect(container.querySelectorAll(".MuiTooltip").length).toBe(0)
   310    })
   311  })
   312  
   313  describe("overview table without groups", () => {
   314    let view: TestDataView
   315    let container: HTMLElement
   316  
   317    beforeEach(() => {
   318      view = nResourceView(8)
   319      container = renderContainer(
   320        tableViewWithSettings({ view, labelsEnabled: true })
   321      )
   322    })
   323  
   324    describe("sorting", () => {
   325      it("table column header displays ascending arrow when sorted ascending", () => {
   326        userEvent.click(findTableHeaderByName("Pod ID"))
   327        const arrowIcon = findTableHeaderByName("Pod ID").querySelector(
   328          ResourceTableHeaderSortTriangle
   329        )
   330  
   331        expect(arrowIcon.classList.contains("is-sorted-asc")).toBe(true)
   332      })
   333  
   334      it("table column header displays descending arrow when sorted descending", () => {
   335        userEvent.click(findTableHeaderByName("Pod ID"))
   336        userEvent.click(findTableHeaderByName("Pod ID"))
   337        const arrowIcon = findTableHeaderByName("Pod ID").querySelector(
   338          ResourceTableHeaderSortTriangle
   339        )
   340  
   341        expect(arrowIcon.classList.contains("is-sorted-desc")).toBe(true)
   342      })
   343    })
   344  })
   345  
   346  describe("overview table with groups", () => {
   347    let view: TestDataView
   348    let container: HTMLElement
   349    let resources: GroupByLabelView<RowValues>
   350  
   351    beforeEach(() => {
   352      view = nResourceWithLabelsView(8)
   353      container = renderContainer(
   354        tableViewWithSettings({ view, labelsEnabled: true })
   355      )
   356      resources = labeledResourcesToTableCells(
   357        view.uiResources,
   358        view.uiButtons,
   359        new LogStore()
   360      )
   361  
   362      mockAnalyticsCalls()
   363      sessionStorage.clear()
   364      localStorage.clear()
   365    })
   366  
   367    afterEach(() => {
   368      cleanupMockAnalyticsCalls()
   369      sessionStorage.clear()
   370      localStorage.clear()
   371    })
   372  
   373    describe("display", () => {
   374      it("does not show the resource groups tooltip", () => {
   375        expect(container.querySelectorAll(".MuiTooltip").length).toBe(0)
   376      })
   377  
   378      it("renders each label group in order", () => {
   379        const { labels: sortedLabels } = resources
   380        const groupNames = container.querySelectorAll(OverviewGroupName)
   381  
   382        // Loop through the sorted labels (which includes every label
   383        // attached to a resource, but not unlabeled or tiltfile "labels")
   384        sortedLabels.forEach((label, idx) => {
   385          const groupName = groupNames[idx]
   386          expect(groupName.textContent).toBe(label)
   387        })
   388      })
   389  
   390      // Note: the sample data generated in the test helper `nResourcesWithLabels`
   391      // always includes unlabeled resources
   392      it("renders a resource group for unlabeled resources and for Tiltfiles", () => {
   393        const groupNames = Array.from(
   394          container.querySelectorAll(OverviewGroupName)
   395        )
   396        expect(screen.getAllByText(UNLABELED_LABEL)).toBeTruthy()
   397        expect(screen.getAllByText(TILTFILE_LABEL)).toBeTruthy()
   398      })
   399  
   400      it("renders a table for each resource group", () => {
   401        const tables = container.querySelectorAll("table")
   402        const totalLabelCount =
   403          resources.labels.length +
   404          (resources.tiltfile.length ? 1 : 0) +
   405          (resources.unlabeled.length ? 1 : 0)
   406  
   407        expect(tables.length).toBe(totalLabelCount)
   408      })
   409  
   410      it("renders the correct resources in each label group", () => {
   411        const { labelsToResources, unlabeled, tiltfile } = resources
   412        const resourceGroups = container.querySelectorAll(OverviewGroup)
   413  
   414        const actualResourcesFromTable: { [key: string]: string[] } = {}
   415        const expectedResourcesFromLabelGroups: { [key: string]: string[] } = {}
   416  
   417        // Create a dictionary of labels to a list of resource names
   418        // based on the view
   419        Object.keys(labelsToResources).forEach((label) => {
   420          const resourceNames = labelsToResources[label].map((r) => r.name)
   421          expectedResourcesFromLabelGroups[label] = resourceNames
   422        })
   423  
   424        expectedResourcesFromLabelGroups[UNLABELED_LABEL] = unlabeled.map(
   425          (r) => r.name
   426        )
   427        expectedResourcesFromLabelGroups[TILTFILE_LABEL] = tiltfile.map(
   428          (r) => r.name
   429        )
   430  
   431        // Create a dictionary of labels to a list of resource names
   432        // based on what's rendered in each group table
   433        resourceGroups.forEach((group: any) => {
   434          // Find the label group name
   435          const groupName = group.querySelector(OverviewGroupName).textContent
   436          // Find the resource list displayed in the table
   437          const table = group.querySelector("table")
   438          const resourcesInTable = Array.from(table.querySelectorAll(Name)).map(
   439            (resourceName: any) => resourceName.textContent
   440          )
   441  
   442          actualResourcesFromTable[groupName] = resourcesInTable
   443        })
   444  
   445        expect(actualResourcesFromTable).toEqual(expectedResourcesFromLabelGroups)
   446      })
   447    })
   448  
   449    describe("resource status summary", () => {
   450      it("renders summaries for each label group", () => {
   451        const summaries = container.querySelectorAll(ResourceStatusSummaryRoot)
   452        const totalLabelCount =
   453          resources.labels.length +
   454          (resources.tiltfile.length ? 1 : 0) +
   455          (resources.unlabeled.length ? 1 : 0)
   456  
   457        expect(summaries.length).toBe(totalLabelCount)
   458      })
   459    })
   460  
   461    describe("expand and collapse", () => {
   462      let groups: NodeListOf<Element>
   463  
   464      // Helpers
   465      const getResourceGroups = () => container.querySelectorAll(OverviewGroup)
   466  
   467      beforeEach(() => {
   468        groups = getResourceGroups()
   469      })
   470  
   471      it("displays as expanded or collapsed based on the ResourceGroupContext", () => {
   472        // Create an existing randomized group state from the labels
   473        const { labels } = resources
   474        const testData: GroupsState = [
   475          ...labels,
   476          UNLABELED_LABEL,
   477          TILTFILE_LABEL,
   478        ].reduce((groupsState: GroupsState, label) => {
   479          const randomLabelState = Math.random() > 0.5
   480          groupsState[label] = {
   481            ...DEFAULT_GROUP_STATE,
   482            expanded: randomLabelState,
   483          }
   484  
   485          return groupsState
   486        }, {})
   487        // Re-mount the component with the initial groups context values
   488        container = renderContainer(
   489          <MemoryRouter initialEntries={["/"]}>
   490            <ResourceGroupsContextProvider initialValuesForTesting={testData}>
   491              <ResourceSelectionProvider>
   492                <TableGroupedByLabels
   493                  resources={view.uiResources}
   494                  buttons={view.uiButtons}
   495                />
   496              </ResourceSelectionProvider>
   497            </ResourceGroupsContextProvider>
   498          </MemoryRouter>
   499        )
   500  
   501        // Loop through each resource group and expect that its expanded state
   502        // matches with the hardcoded test data
   503        const actualExpandedState: GroupsState = {}
   504        container.querySelectorAll(OverviewGroup).forEach((group: any) => {
   505          const groupName = group.querySelector(OverviewGroupName).textContent
   506          actualExpandedState[groupName] = {
   507            expanded: group.classList.contains("Mui-expanded"),
   508          }
   509        })
   510  
   511        expect(actualExpandedState).toEqual(testData)
   512      })
   513  
   514      it("is collapsed when an expanded resource group summary is clicked on", () => {
   515        const group = groups[0]
   516        expect(group.classList.contains("Mui-expanded")).toBe(true)
   517  
   518        userEvent.click(group.querySelector('[role="button"]') as Element)
   519  
   520        // Manually refresh the test component tree
   521        groups = getResourceGroups()
   522  
   523        const updatedGroup = groups[0]
   524        expect(updatedGroup.classList.contains("Mui-expanded")).toBe(false)
   525      })
   526  
   527      it("is expanded when a collapsed resource group summary is clicked on", () => {
   528        // Because groups are expanded by default, click on it once to get it
   529        // into a collapsed state for testing
   530        const initialGroup = groups[0]
   531        expect(initialGroup.classList.contains("Mui-expanded")).toBe(true)
   532  
   533        userEvent.click(initialGroup.querySelector('[role="button"]') as Element)
   534  
   535        const group = getResourceGroups()[0]
   536        expect(group.classList.contains("Mui-expanded")).toBe(false)
   537  
   538        userEvent.click(group.querySelector('[role="button"]')!)
   539  
   540        const updatedGroup = getResourceGroups()[0]
   541        expect(updatedGroup.classList.contains("Mui-expanded")).toBe(true)
   542      })
   543    })
   544  
   545    describe("sorting", () => {
   546      let firstTableNameColumn: any
   547  
   548      beforeEach(() => {
   549        // Find and click the "Resource Name" column on the first table group
   550        firstTableNameColumn = screen.getAllByTitle("Sort by Resource Name")[0]
   551        userEvent.click(firstTableNameColumn)
   552      })
   553  
   554      it("tables sort by ascending values when clicked once", () => {
   555        // Use the fourth resource group table, since it has multiple resources in the test data generator
   556        const ascendingNames = Array.from(
   557          container.querySelectorAll("table")[3].querySelectorAll(Name)
   558        )
   559        const expectedNames = ["_1", "_3", "_5", "_7", "a_failed_build"]
   560        const actualNames = ascendingNames.map((name: any) => name.textContent)
   561  
   562        expect(actualNames).toStrictEqual(expectedNames)
   563      })
   564  
   565      it("tables sort by descending values when clicked twice", () => {
   566        userEvent.click(firstTableNameColumn)
   567  
   568        // Use the fourth resource group table, since it has multiple resources in the test data generator
   569        const descendingNames = Array.from(
   570          container.querySelectorAll("table")[3].querySelectorAll(Name)
   571        )
   572        const expectedNames = ["a_failed_build", "_7", "_5", "_3", "_1"]
   573        const actualNames = descendingNames.map((name: any) => name.textContent)
   574  
   575        expect(actualNames).toStrictEqual(expectedNames)
   576      })
   577  
   578      it("tables un-sort when clicked thrice", () => {
   579        userEvent.click(firstTableNameColumn)
   580        userEvent.click(firstTableNameColumn)
   581  
   582        // Use the fourth resource group table, since it has multiple resources in the test data generator
   583        const unsortedNames = Array.from(
   584          container.querySelectorAll("table")[3].querySelectorAll(Name)
   585        )
   586        const expectedNames = ["_1", "_3", "_5", "_7", "a_failed_build"]
   587        const actualNames = unsortedNames.map((name: any) => name.textContent)
   588  
   589        expect(actualNames).toStrictEqual(expectedNames)
   590      })
   591    })
   592  
   593    describe("resource name filter", () => {
   594      it("does not display tables in groups when a resource filter is applied", () => {
   595        const nameFilterContainer = renderContainer(
   596          tableViewWithSettings({
   597            view,
   598            labelsEnabled: true,
   599            resourceListOptions: {
   600              resourceNameFilter: "filtering!",
   601              alertsOnTop: false,
   602              showDisabledResources: true,
   603            },
   604          })
   605        )
   606  
   607        expect(
   608          nameFilterContainer.querySelectorAll(OverviewGroupName).length
   609        ).toBe(0)
   610      })
   611    })
   612  })
   613  
   614  describe("when disable resources feature is enabled and `showDisabledResources` option is true", () => {
   615    let view: TestDataView
   616    let container: HTMLElement
   617  
   618    beforeEach(() => {
   619      view = nResourceView(4)
   620      // Add two disabled resources to view and place them throughout list
   621      const firstDisabledResource = oneResource({
   622        name: "zee_disabled_resource",
   623        disabled: true,
   624      })
   625      const secondDisabledResource = oneResource({
   626        name: "_0_disabled_resource",
   627        disabled: true,
   628      })
   629      view.uiResources.unshift(firstDisabledResource)
   630      view.uiResources.push(secondDisabledResource)
   631      // Add a button to the first disabled resource
   632      view.uiButtons = [
   633        oneUIButton({ componentID: firstDisabledResource.metadata!.name }),
   634      ]
   635      container = renderContainer(
   636        tableViewWithSettings({
   637          view,
   638          resourceListOptions: {
   639            ...DEFAULT_OPTIONS,
   640            showDisabledResources: true,
   641          },
   642        })
   643      )
   644    })
   645  
   646    it("displays disabled resources at the bottom of the table", () => {
   647      const visibleResources = Array.from(container.querySelectorAll(Name))
   648      const resourceNamesInOrder = visibleResources.map((r: any) => r.textContent)
   649      expect(resourceNamesInOrder.length).toBe(6)
   650  
   651      const expectedNameOrder = [
   652        "(Tiltfile)",
   653        "_1",
   654        "_2",
   655        "_3",
   656        "zee_disabled_resource",
   657        "_0_disabled_resource",
   658      ]
   659  
   660      expect(resourceNamesInOrder).toStrictEqual(expectedNameOrder)
   661    })
   662  
   663    it("sorts disabled resources along with enabled resources", () => {
   664      // Click twice to sort by resource name descending (Z -> A)
   665      let header = findTableHeaderByName("Resource Name", true)
   666      userEvent.click(header)
   667      userEvent.click(header)
   668  
   669      const resourceNamesInOrder = Array.from(
   670        container.querySelectorAll(Name)
   671      ).map((r: any) => r.textContent)
   672  
   673      const expectedNameOrder = [
   674        "zee_disabled_resource",
   675        "_3",
   676        "_2",
   677        "_1",
   678        "_0_disabled_resource",
   679        "(Tiltfile)",
   680      ]
   681  
   682      expect(resourceNamesInOrder).toStrictEqual(expectedNameOrder)
   683    })
   684  
   685    it("does NOT display controls for a disabled resource", () => {
   686      // Get the last resource table row, which should be a disabled resource
   687      const disabledResource = container.querySelectorAll(ResourceTableRow)[5]
   688      const resourceName = disabledResource.querySelector(Name)
   689  
   690      let buttons = Array.from(disabledResource.querySelectorAll("button"))
   691        // Remove disabled buttons
   692        .filter((button: any) => !button.classList.contains("is-disabled"))
   693        .filter((button: any) => !button.classList.contains("isDisabled"))
   694        // Remove the star button
   695        .filter((button: any) => button.title != "Star this Resource")
   696      expect(resourceName!.textContent).toBe("zee_disabled_resource")
   697      expect(buttons).toHaveLength(0)
   698    })
   699  
   700    it("adds `isDisabled` class to table rows for disabled resources", () => {
   701      // Expect two disabled resources based on hardcoded test data
   702      const disabledRows = container.querySelectorAll(
   703        ResourceTableRow + ".isDisabled"
   704      )
   705      expect(disabledRows.length).toBe(2)
   706    })
   707  })
   708  
   709  describe("`showDisabledResources` option is false", () => {
   710    it("does NOT display disabled resources", () => {
   711      const view = nResourceView(8)
   712      // Add a disabled resource to view
   713      const disabledResource = oneResource({
   714        name: "disabled_resource",
   715        disabled: true,
   716      })
   717      view.uiResources.push(disabledResource)
   718  
   719      const { container } = render(
   720        tableViewWithSettings({
   721          view,
   722          resourceListOptions: {
   723            ...DEFAULT_OPTIONS,
   724            showDisabledResources: false,
   725          },
   726        })
   727      )
   728  
   729      const visibleResources = Array.from(container.querySelectorAll(Name))
   730      const resourceNames = visibleResources.map((r) => r.textContent)
   731      expect(resourceNames.length).toBe(8)
   732      expect(resourceNames).not.toContain("disabled_resource")
   733    })
   734  })
   735  
   736  describe("bulk disable actions", () => {
   737    function allEnabledCheckboxes(el: HTMLElement) {
   738      return Array.from(
   739        el.querySelectorAll(`${SelectionCheckbox}:not(.Mui-disabled)`)
   740      )
   741    }
   742  
   743    describe("when disable resources feature is enabled", () => {
   744      let view: TestDataView
   745      let container: HTMLElement
   746  
   747      beforeEach(() => {
   748        view = nResourceView(4)
   749        container = renderContainer(tableViewWithSettings({ view }))
   750      })
   751  
   752      it("renders labels on enabled checkbox", () => {
   753        let els = container.querySelectorAll(
   754          `${SelectionCheckbox}:not(.Mui-disabled)`
   755        )
   756        expect(els[0].getAttribute("aria-label")).toBe("Resource group selection")
   757        expect(els[1].getAttribute("aria-label")).toBe("Select resource")
   758      })
   759  
   760      it("renders labels on disabled checkbox", () => {
   761        let el = container.querySelector(`${SelectionCheckbox}.Mui-disabled`)
   762        expect(el!.getAttribute("aria-label")).toBe("Cannot select resource")
   763      })
   764  
   765      it("renders the `Select` column", () => {
   766        expect(
   767          container.querySelectorAll(SelectionCheckbox).length
   768        ).toBeGreaterThan(0)
   769      })
   770  
   771      it("renders a checkbox for the column header and every resource that is selectable", () => {
   772        const expectedCheckBoxDisplay = {
   773          "(Tiltfile)": false,
   774          columnHeader: true,
   775          _1: true,
   776          _2: true,
   777          _3: true,
   778        }
   779        const actualCheckboxDisplay: { [key: string]: boolean } = {}
   780        const rows = Array.from(container.querySelectorAll(ResourceTableRow))
   781        rows.forEach((row: any, idx: number) => {
   782          let name: string
   783          if (idx === 0) {
   784            name = "columnHeader"
   785          } else {
   786            name = row.querySelector(Name).textContent
   787          }
   788  
   789          const checkbox = allEnabledCheckboxes(row)
   790          actualCheckboxDisplay[name] = checkbox.length === 1
   791        })
   792  
   793        expect(actualCheckboxDisplay).toEqual(expectedCheckBoxDisplay)
   794      })
   795  
   796      it("selects a resource when checkbox is not checked", () => {
   797        const checkbox = allEnabledCheckboxes(container)[1]
   798        userEvent.click(checkbox.querySelector("input")!)
   799  
   800        const checkboxAfterClick = allEnabledCheckboxes(container)[1]
   801        expect(checkboxAfterClick.getAttribute("aria-checked")).toBe("true")
   802      })
   803  
   804      it("deselects a resource when checkbox is checked", () => {
   805        const checkbox = allEnabledCheckboxes(container)[1]
   806        expect(checkbox).toBeTruthy()
   807  
   808        // Click the checkbox once to get it to a selected state
   809        userEvent.click(checkbox.querySelector("input")!)
   810  
   811        const checkboxAfterFirstClick = allEnabledCheckboxes(container)[1]
   812        expect(checkboxAfterFirstClick.getAttribute("aria-checked")).toBe("true")
   813  
   814        // Click the checkbox a second time to deselect it
   815        userEvent.click(checkbox.querySelector("input")!)
   816  
   817        const checkboxAfterSecondClick = allEnabledCheckboxes(container)[1]
   818        expect(checkboxAfterSecondClick.getAttribute("aria-checked")).toBe(
   819          "false"
   820        )
   821      })
   822  
   823      describe("selection checkbox header", () => {
   824        it("displays as unchecked if no resources in the table are checked", () => {
   825          const allCheckboxes = allEnabledCheckboxes(container)
   826          let checkbox: any = allCheckboxes[0]
   827          const headerCheckboxCheckedState = checkbox.getAttribute("aria-checked")
   828          const headerCheckboxIndeterminateState = checkbox
   829            .querySelector("[data-indeterminate]")
   830            .getAttribute("data-indeterminate")
   831          const rowCheckboxesState = allCheckboxes
   832            .slice(1)
   833            .map((checkbox: any) => checkbox.getAttribute("aria-checked"))
   834  
   835          expect(rowCheckboxesState).toStrictEqual(["false", "false", "false"])
   836          expect(headerCheckboxCheckedState).toBe("false")
   837          expect(headerCheckboxIndeterminateState).toBe("false")
   838        })
   839  
   840        it("displays as indeterminate if some but not all resources in the table are checked", () => {
   841          // Choose a (random) table row to click and select
   842          const resourceCheckbox: any = allEnabledCheckboxes(container)[2]
   843          userEvent.click(resourceCheckbox.querySelector("input"))
   844  
   845          // Verify that the header checkbox displays as partially selected
   846          const headerCheckboxCheckedState = container
   847            .querySelector(SelectionCheckbox)!
   848            .getAttribute("aria-checked")
   849          const headerCheckboxIndeterminateState = container
   850            .querySelector(`${SelectionCheckbox} [data-indeterminate]`)!
   851            .getAttribute("data-indeterminate")
   852  
   853          expect(headerCheckboxCheckedState).toBe("false")
   854          expect(headerCheckboxIndeterminateState).toBe("true")
   855        })
   856  
   857        it("displays as checked if all resources in the table are checked", () => {
   858          // Click all checkboxes for resource rows, skipping the first one (which is the table header row)
   859          allEnabledCheckboxes(container)
   860            .slice(1)
   861            .forEach((resourceCheckbox: any) => {
   862              userEvent.click(resourceCheckbox.querySelector("input"))
   863            })
   864  
   865          // Verify that the header checkbox displays as partially selected
   866          const headerCheckboxCheckedState = container
   867            .querySelector(SelectionCheckbox)!
   868            .getAttribute("aria-checked")
   869          const headerCheckboxIndeterminateState = container
   870            .querySelector(`${SelectionCheckbox} [data-indeterminate]`)!
   871            .getAttribute("data-indeterminate")
   872  
   873          expect(headerCheckboxCheckedState).toBe("true")
   874          expect(headerCheckboxIndeterminateState).toBe("false")
   875        })
   876  
   877        it("selects every resource in the table when checkbox is not checked", () => {
   878          // Click the header checkbox to select it
   879          const headerCheckbox = allEnabledCheckboxes(container)[0]
   880          userEvent.click(headerCheckbox.querySelector("input")!)
   881  
   882          // Verify all table resources are now selected
   883          const rowCheckboxesState = allEnabledCheckboxes(container)
   884            .slice(1)
   885            .map((checkbox: any) => checkbox.getAttribute("aria-checked"))
   886          expect(rowCheckboxesState).toStrictEqual(["true", "true", "true"])
   887        })
   888  
   889        it("deselects every resource in the table when checkbox is checked", () => {
   890          const headerCheckbox = container.querySelector(SelectionCheckbox)!
   891          userEvent.click(headerCheckbox.querySelector("input")!)
   892  
   893          // Click the checkbox a second time to deselect it
   894          const headerCheckboxAfterFirstClick =
   895            container.querySelector(SelectionCheckbox)!
   896          userEvent.click(headerCheckboxAfterFirstClick.querySelector("input")!)
   897  
   898          // Verify all table resources are now deselected
   899          const rowCheckboxesState = allEnabledCheckboxes(container)
   900            .slice(1)
   901            .map((checkbox: any) => checkbox.getAttribute("aria-checked"))
   902          expect(rowCheckboxesState).toStrictEqual(["false", "false", "false"])
   903        })
   904      })
   905    })
   906  })
   907  
   908  // https://github.com/tilt-dev/tilt/issues/5754
   909  it("renders the trigger mode column correctly", () => {
   910    const view = nResourceView(2)
   911    view.uiResources = [
   912      oneResource({ name: "r1", triggerMode: TriggerMode.TriggerModeAuto }),
   913      oneResource({ name: "r2", triggerMode: TriggerMode.TriggerModeManual }),
   914    ]
   915    const container = renderContainer(tableViewWithSettings({ view: view }))
   916  
   917    const isToggleContent = (content: string) =>
   918      content == ToggleTriggerModeTooltip.isAuto ||
   919      content == ToggleTriggerModeTooltip.isManual
   920  
   921    let modes = Array.from(screen.getAllByTitle(isToggleContent)).map(
   922      (n) => n.title
   923    )
   924    expect(modes).toEqual([
   925      ToggleTriggerModeTooltip.isAuto,
   926      ToggleTriggerModeTooltip.isManual,
   927    ])
   928  })
   929  
   930  function renderContainer(x: ReactElement) {
   931    let { container } = render(x)
   932    return container
   933  }