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