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