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

     1  import {
     2    render,
     3    RenderOptions,
     4    RenderResult,
     5    screen,
     6    waitFor,
     7  } from "@testing-library/react"
     8  import userEvent from "@testing-library/user-event"
     9  import fetchMock from "fetch-mock"
    10  import { SnackbarProvider } from "notistack"
    11  import React, { PropsWithChildren } from "react"
    12  import { MemoryRouter } from "react-router"
    13  import {
    14    ApiButton,
    15    ApiButtonType,
    16    buttonsByComponent,
    17    ButtonSet,
    18  } from "./ApiButton"
    19  import { mockUIButtonUpdates } from "./ApiButton.testhelpers"
    20  import { accessorsForTesting, tiltfileKeyContext } from "./BrowserStorage"
    21  import { HudErrorContextProvider } from "./HudErrorContext"
    22  import {
    23    boolFieldForUIButton,
    24    choiceFieldForUIButton,
    25    disableButton,
    26    hiddenFieldForUIButton,
    27    oneUIButton,
    28    textFieldForUIButton,
    29  } from "./testdata"
    30  import { UIButton, UIButtonStatus, UIInputSpec } from "./types"
    31  
    32  const buttonInputsAccessor = accessorsForTesting(
    33    `apibutton-TestButton`,
    34    localStorage
    35  )
    36  
    37  type ApiButtonProviderProps = {
    38    setError?: (error: string) => void
    39  }
    40  
    41  function ApiButtonProviders({
    42    children,
    43    setError,
    44  }: PropsWithChildren<ApiButtonProviderProps>) {
    45    return (
    46      <MemoryRouter>
    47        <HudErrorContextProvider setError={setError ?? (() => {})}>
    48          <tiltfileKeyContext.Provider value="test">
    49            <SnackbarProvider>{children}</SnackbarProvider>
    50          </tiltfileKeyContext.Provider>
    51        </HudErrorContextProvider>
    52      </MemoryRouter>
    53    )
    54  }
    55  
    56  // Following the custom render example from RTL:
    57  // https://testing-library.com/docs/react-testing-library/setup/#custom-render
    58  function customRender(
    59    component: JSX.Element,
    60    options?: RenderOptions,
    61    providerProps?: ApiButtonProviderProps
    62  ) {
    63    return render(component, {
    64      wrapper: ({ children }) => (
    65        <ApiButtonProviders {...providerProps} children={children} />
    66      ),
    67      ...options,
    68    })
    69  }
    70  
    71  describe("ApiButton", () => {
    72    beforeEach(() => {
    73      localStorage.clear()
    74      mockUIButtonUpdates()
    75      Date.now = jest.fn(() => 1482363367071)
    76    })
    77  
    78    afterEach(() => {
    79      localStorage.clear()
    80      fetchMock.reset()
    81    })
    82  
    83    it("renders a simple button", () => {
    84      const uibutton = oneUIButton({ iconName: "flight_takeoff" })
    85      customRender(<ApiButton uiButton={uibutton} />)
    86  
    87      const buttonElement = screen.getByLabelText(
    88        `Trigger ${uibutton.spec!.text!}`
    89      )
    90      expect(buttonElement).toBeInTheDocument()
    91      expect(buttonElement).toHaveTextContent(uibutton.spec!.text!)
    92      expect(screen.getByText(uibutton.spec!.iconName!)).toBeInTheDocument()
    93    })
    94  
    95    it("sets a hud error when the api request fails", async () => {
    96      // To add a mocked error response, reset the current mock
    97      // for UIButton API call and add back the mock for analytics calls
    98      // Reset the current mock for UIButton to add fake error response
    99      fetchMock.reset()
   100      fetchMock.put(
   101        (url) => url.startsWith("/proxy/apis/tilt.dev/v1alpha1/uibuttons"),
   102        { throws: "broken!" }
   103      )
   104  
   105      let error: string | undefined
   106      const setError = (e: string) => (error = e)
   107      const uibutton = oneUIButton({})
   108      customRender(<ApiButton uiButton={uibutton} />, {}, { setError })
   109  
   110      userEvent.click(screen.getByRole("button"))
   111  
   112      await waitFor(() => {
   113        expect(screen.getByRole("button")).not.toBeDisabled()
   114      })
   115  
   116      expect(error).toEqual("Error submitting button click: broken!")
   117    })
   118  
   119    describe("button with visible inputs", () => {
   120      let uibutton: UIButton
   121      let inputSpecs: UIInputSpec[]
   122      beforeEach(() => {
   123        inputSpecs = [
   124          textFieldForUIButton("text_field"),
   125          boolFieldForUIButton("bool_field", false),
   126          textFieldForUIButton("text_field_with_default", "default text"),
   127          hiddenFieldForUIButton("hidden_field", "hidden value 1"),
   128          choiceFieldForUIButton("choice_field", [
   129            "choice1",
   130            "choice2",
   131            "choice3",
   132          ]),
   133        ]
   134        uibutton = oneUIButton({ inputSpecs })
   135        customRender(<ApiButton uiButton={uibutton} />).rerender
   136      })
   137  
   138      it("renders the button with inputs", () => {
   139        expect(
   140          screen.getByLabelText(`Trigger ${uibutton.spec!.text!}`)
   141        ).toBeInTheDocument()
   142      })
   143  
   144      it("shows the modal with inputs when the button is clicked", () => {
   145        const button = screen.getByLabelText(`Trigger ${uibutton.spec!.text!}`)
   146        userEvent.click(button)
   147  
   148        expect(
   149          screen.getByText(`Configure ${uibutton.spec!.text!}`)
   150        ).toBeInTheDocument()
   151      })
   152  
   153      it("only shows inputs for visible inputs", () => {
   154        // Open the modal by clicking the button
   155        const button = screen.getByLabelText(`Trigger ${uibutton.spec!.text!}`)
   156        userEvent.click(button)
   157  
   158        inputSpecs.forEach((spec) => {
   159          if (!spec.hidden) {
   160            expect(screen.getByLabelText(spec.label!)).toBeInTheDocument()
   161          }
   162        })
   163      })
   164  
   165      it("allows an empty text string when there's a default value", async () => {
   166        // Open the modal by clicking the button
   167        const button = screen.getByLabelText(`Trigger ${uibutton.spec!.text!}`)
   168        userEvent.click(button)
   169  
   170        // Get the input element with the hardcoded default text
   171        const inputWithDefault = screen.getByDisplayValue("default text")
   172        userEvent.clear(inputWithDefault)
   173  
   174        // Use the label text to select and verify the input's value
   175        expect(screen.getByLabelText("text_field_with_default")).toHaveValue("")
   176      })
   177  
   178      it("submits the current options when the submit button is clicked", async () => {
   179        // Open the modal by clicking the button
   180        const button = screen.getByLabelText(`Trigger ${uibutton.spec!.text!}`)
   181        userEvent.click(button)
   182  
   183        // Make a couple changes to the inputs
   184        userEvent.type(screen.getByLabelText("text_field"), "new_value")
   185        userEvent.click(screen.getByLabelText("bool_field"))
   186        userEvent.type(screen.getByLabelText("text_field_with_default"), "!!!!")
   187        userEvent.click(screen.getByLabelText("choice_field"))
   188        userEvent.click(screen.getByText("choice1"))
   189        userEvent.click(screen.getByText("choice3"))
   190  
   191        // Click the confirm button in modal
   192        userEvent.click(screen.getByText("Confirm & Execute"))
   193  
   194        // Wait for the button to be enabled again,
   195        // which signals successful trigger button response
   196        await waitFor(
   197          () =>
   198            expect(screen.getByLabelText(`Trigger ${uibutton.spec!.text!}`)).not
   199              .toBeDisabled
   200        )
   201  
   202        const calls = fetchMock.calls()
   203        expect(calls.length).toEqual(1)
   204        const call = calls[0]
   205        expect(call[0]).toEqual(
   206          "/proxy/apis/tilt.dev/v1alpha1/uibuttons/TestButton/status"
   207        )
   208        expect(call[1]).toBeTruthy()
   209        expect(call[1]!.method).toEqual("PUT")
   210        expect(call[1]!.body).toBeTruthy()
   211        const actualStatus: UIButtonStatus = JSON.parse(
   212          call[1]!.body!.toString()
   213        ).status
   214  
   215        const expectedStatus: UIButtonStatus = {
   216          lastClickedAt: "2016-12-21T23:36:07.071000+00:00",
   217          inputs: [
   218            {
   219              name: inputSpecs[0].name,
   220              text: {
   221                value: "new_value",
   222              },
   223            },
   224            {
   225              name: inputSpecs[1].name,
   226              bool: {
   227                value: true,
   228              },
   229            },
   230            {
   231              name: inputSpecs[2].name,
   232              text: {
   233                value: "default text!!!!",
   234              },
   235            },
   236            {
   237              name: inputSpecs[3].name,
   238              hidden: {
   239                value: inputSpecs[3].hidden!.value,
   240              },
   241            },
   242            {
   243              name: inputSpecs[4].name,
   244              choice: {
   245                value: "choice3",
   246              },
   247            },
   248          ],
   249        }
   250        expect(actualStatus).toEqual(expectedStatus)
   251      })
   252  
   253      it("submits default options when the submit button is clicked", async () => {
   254        // Open the modal
   255        userEvent.click(screen.getByLabelText(`Trigger ${uibutton.spec!.text!}`))
   256  
   257        // Click confirm in modal
   258        userEvent.click(screen.getByText("Confirm & Execute"))
   259  
   260        // Wait for the modal to close and API call to complete
   261        await waitFor(() =>
   262          expect(screen.queryByText("Confirm & Execute")).not.toBeInTheDocument()
   263        )
   264  
   265        const calls = fetchMock.calls()
   266        expect(calls.length).toEqual(1)
   267        const call = calls[0]
   268        expect(call[0]).toEqual(
   269          "/proxy/apis/tilt.dev/v1alpha1/uibuttons/TestButton/status"
   270        )
   271        expect(call[1]).toBeTruthy()
   272        expect(call[1]!.method).toEqual("PUT")
   273        expect(call[1]!.body).toBeTruthy()
   274        const actualStatus: UIButtonStatus = JSON.parse(
   275          call[1]!.body!.toString()
   276        ).status
   277  
   278        const expectedStatus: UIButtonStatus = {
   279          lastClickedAt: "2016-12-21T23:36:07.071000+00:00",
   280          inputs: [
   281            {
   282              name: inputSpecs[0].name,
   283              text: {},
   284            },
   285            {
   286              name: inputSpecs[1].name,
   287              bool: {
   288                value: false,
   289              },
   290            },
   291            {
   292              name: inputSpecs[2].name,
   293              text: {
   294                value: "default text",
   295              },
   296            },
   297            {
   298              name: inputSpecs[3].name,
   299              hidden: {
   300                value: inputSpecs[3].hidden!.value,
   301              },
   302            },
   303            {
   304              name: inputSpecs[4].name,
   305              choice: {
   306                value: "choice1",
   307              },
   308            },
   309          ],
   310        }
   311        expect(actualStatus).toEqual(expectedStatus)
   312      })
   313    })
   314  
   315    describe("local storage for input values", () => {
   316      let uibutton: UIButton
   317      let inputSpecs: UIInputSpec[]
   318      beforeEach(() => {
   319        inputSpecs = [
   320          textFieldForUIButton("text1"),
   321          boolFieldForUIButton("bool1"),
   322        ]
   323        uibutton = oneUIButton({ inputSpecs })
   324  
   325        // Store previous values for input fields
   326        buttonInputsAccessor.set({
   327          text1: "text value",
   328          bool1: true,
   329        })
   330  
   331        customRender(<ApiButton uiButton={uibutton} />)
   332      })
   333  
   334      it("are read from local storage", () => {
   335        // Open the modal
   336        userEvent.click(screen.getByLabelText(`Trigger ${uibutton.spec!.text!}`))
   337  
   338        expect(screen.getByLabelText("text1")).toHaveValue("text value")
   339        expect(screen.getByLabelText("bool1")).toBeChecked()
   340      })
   341  
   342      it("are written to local storage when modal is confirmed", () => {
   343        // Open the modal
   344        userEvent.click(screen.getByLabelText(`Trigger ${uibutton.spec!.text!}`))
   345  
   346        // Type a new value in the text field
   347        const textField = screen.getByLabelText("text1")
   348        userEvent.clear(textField)
   349        userEvent.type(textField, "new value!")
   350  
   351        // Uncheck the boolean field
   352        userEvent.click(screen.getByLabelText("bool1"))
   353  
   354        // Confirm the modal to persist values
   355        userEvent.click(screen.getByText("Confirm & Execute"))
   356  
   357        // Expect local storage values are updated after confirmation
   358        expect(buttonInputsAccessor.get()).toEqual({
   359          text1: "new value!",
   360          bool1: false,
   361        })
   362      })
   363    })
   364  
   365    describe("button with only hidden inputs", () => {
   366      let uibutton: UIButton
   367      beforeEach(() => {
   368        const inputSpecs = [1, 2, 3].map((i) =>
   369          hiddenFieldForUIButton(`hidden${i}`, `value${i}`)
   370        )
   371        uibutton = oneUIButton({ inputSpecs })
   372        customRender(<ApiButton uiButton={oneUIButton({ inputSpecs })} />)
   373      })
   374  
   375      it("doesn't render an options button", () => {
   376        expect(
   377          screen.queryByLabelText(`Open ${uibutton.spec!.text!} options`)
   378        ).not.toBeInTheDocument()
   379      })
   380  
   381      it("doesn't render any input elements", () => {
   382        expect(screen.queryAllByRole("input").length).toBe(0)
   383      })
   384    })
   385  
   386    describe("buttons that require confirmation", () => {
   387      let uibutton: UIButton
   388      let rerender: RenderResult["rerender"]
   389      beforeEach(() => {
   390        uibutton = oneUIButton({ requiresConfirmation: true })
   391        rerender = customRender(<ApiButton uiButton={uibutton} />).rerender
   392      })
   393  
   394      it("displays 'confirm' and 'cancel' buttons after a single click", () => {
   395        const buttonBeforeClick = screen.getByLabelText(
   396          `Trigger ${uibutton.spec!.text!}`
   397        )
   398        expect(buttonBeforeClick).toBeInTheDocument()
   399        expect(buttonBeforeClick).toHaveTextContent(uibutton.spec!.text!)
   400  
   401        userEvent.click(buttonBeforeClick)
   402  
   403        const confirmButton = screen.getByLabelText(
   404          `Confirm ${uibutton.spec!.text!}`
   405        )
   406        expect(confirmButton).toBeInTheDocument()
   407        expect(confirmButton).toHaveTextContent("Confirm")
   408  
   409        const cancelButton = screen.getByLabelText(
   410          `Cancel ${uibutton.spec!.text!}`
   411        )
   412        expect(cancelButton).toBeInTheDocument()
   413      })
   414  
   415      it("clicking the 'confirm' button triggers a button API call", async () => {
   416        // Click the submit button
   417        userEvent.click(screen.getByLabelText(`Trigger ${uibutton.spec!.text!}`))
   418  
   419        // Expect that it should not have submitted the click to the backend
   420        expect(fetchMock.calls().length).toEqual(0)
   421  
   422        // Click the confirm submit button
   423        userEvent.click(screen.getByLabelText(`Confirm ${uibutton.spec!.text!}`))
   424  
   425        // Wait for the button to be enabled again,
   426        // which signals successful trigger button response
   427        await waitFor(
   428          () =>
   429            expect(screen.getByLabelText(`Trigger ${uibutton.spec!.text!}`)).not
   430              .toBeDisabled
   431        )
   432  
   433        // Expect that the click was submitted and the button text resets
   434        expect(fetchMock.calls().length).toEqual(1)
   435        expect(
   436          screen.getByLabelText(`Trigger ${uibutton.spec!.text!}`)
   437        ).toHaveTextContent(uibutton.spec!.text!)
   438      })
   439  
   440      it("clicking the 'cancel' button resets the button", () => {
   441        // Click the submit button
   442        userEvent.click(screen.getByLabelText(`Trigger ${uibutton.spec!.text!}`))
   443  
   444        // Expect that it should not have submitted the click to the backend
   445        expect(fetchMock.calls().length).toEqual(0)
   446  
   447        // Click the cancel submit button
   448        userEvent.click(screen.getByLabelText(`Cancel ${uibutton.spec!.text!}`))
   449  
   450        // Expect that NO click was submitted and the button text resets
   451        expect(fetchMock.calls().length).toEqual(0)
   452        expect(
   453          screen.getByLabelText(`Trigger ${uibutton.spec!.text!}`)
   454        ).toHaveTextContent(uibutton.spec!.text!)
   455      })
   456  
   457      // This test makes sure that the `confirming` state resets if a user
   458      // clicks a toggle button once, then navigates to another resource
   459      // with a toggle button (which will have a different button name)
   460      it("resets the `confirming` state when the button's name changes", () => {
   461        // Click the button and verify the confirmation state
   462        userEvent.click(screen.getByLabelText(`Trigger ${uibutton.spec!.text!}`))
   463        expect(
   464          screen.getByLabelText(`Confirm ${uibutton.spec!.text!}`)
   465        ).toBeInTheDocument()
   466        expect(
   467          screen.getByLabelText(`Cancel ${uibutton.spec!.text!}`)
   468        ).toBeInTheDocument()
   469  
   470        // Then update the component's props with a new button
   471        const anotherUIButton = oneUIButton({
   472          buttonName: "another-button",
   473          buttonText: "Click another button!",
   474          requiresConfirmation: true,
   475        })
   476        rerender(<ApiButton uiButton={anotherUIButton} />)
   477  
   478        // Verify that the button's confirmation state is reset
   479        // and displays the new button text
   480        const updatedButton = screen.getByLabelText(
   481          `Trigger ${anotherUIButton.spec!.text!}`
   482        )
   483        expect(updatedButton).toBeInTheDocument()
   484        expect(updatedButton).toHaveTextContent(anotherUIButton.spec!.text!)
   485      })
   486    })
   487  
   488    describe("helper functions", () => {
   489      describe("buttonsByComponent", () => {
   490        it("returns an empty object if there are no buttons", () => {
   491          expect(buttonsByComponent(undefined)).toStrictEqual(
   492            new Map<string, ButtonSet>()
   493          )
   494        })
   495  
   496        it("returns a map of resources names to button sets", () => {
   497          const buttons = [
   498            oneUIButton({ componentID: "frontend", buttonName: "Lint" }),
   499            oneUIButton({ componentID: "frontend", buttonName: "Compile" }),
   500            disableButton("frontend", true),
   501            oneUIButton({ componentID: "backend", buttonName: "Random scripts" }),
   502            disableButton("backend", false),
   503            oneUIButton({ componentID: "data-warehouse", buttonName: "Flush" }),
   504            oneUIButton({ componentID: "" }),
   505          ]
   506  
   507          const expectedOutput = new Map<string, ButtonSet>([
   508            [
   509              "frontend",
   510              {
   511                default: [buttons[0], buttons[1]],
   512                toggleDisable: buttons[2],
   513              },
   514            ],
   515            ["backend", { default: [buttons[3]], toggleDisable: buttons[4] }],
   516            ["data-warehouse", { default: [buttons[5]] }],
   517          ])
   518  
   519          expect(buttonsByComponent(buttons)).toStrictEqual(expectedOutput)
   520        })
   521      })
   522    })
   523  })