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