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 })