CardSections.test.jsx (15394B)
1 import React from "react"; 2 import { mount } from "enzyme"; 3 import { Provider } from "react-redux"; 4 import { INITIAL_STATE, reducers } from "common/Reducers.sys.mjs"; 5 import { CardSections } from "content-src/components/DiscoveryStreamComponents/CardSections/CardSections"; 6 import { combineReducers, createStore } from "redux"; 7 import { DSCard } from "../../../../../content-src/components/DiscoveryStreamComponents/DSCard/DSCard"; 8 import { FollowSectionButtonHighlight } from "../../../../../content-src/components/DiscoveryStreamComponents/FeatureHighlight/FollowSectionButtonHighlight"; 9 10 const PREF_SECTIONS_PERSONALIZATION_ENABLED = 11 "discoverystream.sections.personalization.enabled"; 12 13 const DEFAULT_PROPS = { 14 type: "CardGrid", 15 firstVisibleTimeStamp: null, 16 ctaButtonSponsors: [""], 17 anySectionsFollowed: false, 18 data: { 19 sections: [ 20 { 21 data: [ 22 { 23 title: "Card 1", 24 image_src: "image1.jpg", 25 url: "http://example.com", 26 }, 27 {}, 28 {}, 29 {}, 30 ], 31 receivedRank: 0, 32 sectionKey: "section_key", 33 title: "title", 34 layout: { 35 title: "layout_name", 36 responsiveLayouts: [ 37 { 38 columnCount: 1, 39 tiles: [ 40 { 41 size: "large", 42 position: 0, 43 hasAd: false, 44 hasExcerpt: true, 45 }, 46 { 47 size: "small", 48 position: 2, 49 hasAd: false, 50 hasExcerpt: false, 51 }, 52 { 53 size: "medium", 54 position: 1, 55 hasAd: true, 56 hasExcerpt: true, 57 }, 58 { 59 size: "small", 60 position: 3, 61 hasAd: false, 62 hasExcerpt: false, 63 }, 64 ], 65 }, 66 ], 67 }, 68 }, 69 ], 70 }, 71 feed: { 72 embed_reference: null, 73 url: "https://merino.services.mozilla.com/api/v1/curated-recommendations", 74 }, 75 }; 76 77 // Wrap this around any component that uses useSelector, 78 // or any mount that uses a child that uses redux. 79 function WrapWithProvider({ children, state = INITIAL_STATE }) { 80 let store = createStore(combineReducers(reducers), state); 81 return <Provider store={store}>{children}</Provider>; 82 } 83 84 describe("<CardSections />", () => { 85 let wrapper; 86 let sandbox; 87 let dispatch; 88 89 beforeEach(() => { 90 sandbox = sinon.createSandbox(); 91 dispatch = sandbox.stub(); 92 wrapper = mount( 93 <WrapWithProvider> 94 <CardSections dispatch={dispatch} {...DEFAULT_PROPS} /> 95 </WrapWithProvider> 96 ); 97 }); 98 99 afterEach(() => { 100 sandbox.restore(); 101 }); 102 103 it("should render null if no data is provided", () => { 104 // Verify the section exists normally, so the next assertion is unlikely to be a false positive. 105 assert(wrapper.find(".ds-section-wrapper").exists()); 106 107 wrapper = mount( 108 <WrapWithProvider> 109 <CardSections dispatch={dispatch} {...DEFAULT_PROPS} data={null} /> 110 </WrapWithProvider> 111 ); 112 assert(!wrapper.find(".ds-section-wrapper").exists()); 113 }); 114 115 it("should render DSEmptyState if sections are falsey", () => { 116 wrapper = mount( 117 <WrapWithProvider> 118 <CardSections 119 {...DEFAULT_PROPS} 120 data={{ ...DEFAULT_PROPS.data, sections: [] }} 121 /> 122 </WrapWithProvider> 123 ); 124 assert(wrapper.find(".ds-card-grid.empty").exists()); 125 }); 126 127 it("should render sections and DSCard components for valid data", () => { 128 const { sections } = DEFAULT_PROPS.data; 129 const sectionLength = sections.length; 130 assert.lengthOf(wrapper.find("section"), sectionLength); 131 assert.lengthOf(wrapper.find(DSCard), 4); 132 assert.equal(wrapper.find(".section-title").text(), "title"); 133 }); 134 135 it("should skip a section with no items available for that section", () => { 136 // Verify the section exists normally, so the next assertion is unlikely to be a false positive. 137 assert(wrapper.find(".ds-section").exists()); 138 139 wrapper = mount( 140 <WrapWithProvider> 141 <CardSections 142 {...DEFAULT_PROPS} 143 data={{ 144 ...DEFAULT_PROPS.data, 145 sections: [{ ...DEFAULT_PROPS.data.sections[0], data: [] }], 146 }} 147 /> 148 </WrapWithProvider> 149 ); 150 assert(!wrapper.find(".ds-section").exists()); 151 }); 152 153 it("should render a placeholder", () => { 154 wrapper = mount( 155 <WrapWithProvider> 156 <CardSections 157 {...DEFAULT_PROPS} 158 data={{ 159 ...DEFAULT_PROPS.data, 160 sections: [ 161 { 162 ...DEFAULT_PROPS.data.sections[0], 163 data: [{ placeholder: true }], 164 }, 165 ], 166 }} 167 /> 168 </WrapWithProvider> 169 ); 170 assert(wrapper.find(".ds-card.placeholder").exists()); 171 }); 172 173 it("should pass correct props to DSCard", () => { 174 const cardProps = wrapper.find(DSCard).at(0).props(); 175 assert.equal(cardProps.title, "Card 1"); 176 assert.equal(cardProps.image_src, "image1.jpg"); 177 assert.equal(cardProps.url, "http://example.com"); 178 }); 179 180 it("should apply correct classNames and position from layout data", () => { 181 const props = wrapper.find(DSCard).at(0).props(); 182 const thirdProps = wrapper.find(DSCard).at(2).props(); 183 assert.equal( 184 props.sectionsClassNames, 185 "col-1-large col-1-position-0 col-1-show-excerpt" 186 ); 187 assert.equal( 188 thirdProps.sectionsClassNames, 189 "col-1-small col-1-position-1 col-1-hide-excerpt" 190 ); 191 }); 192 193 it("should apply correct class names for cards with and without excerpts", () => { 194 wrapper.find(DSCard).forEach(card => { 195 const props = card.props(); 196 // Small cards don't show excerpts according to the data in DEFAULT_PROPS for this test suite 197 if (props.sectionsClassNames.includes("small")) { 198 assert.include(props.sectionsClassNames, "hide-excerpt"); 199 assert.notInclude(props.sectionsClassNames, "show-excerpt"); 200 } 201 // The other cards should show excerpts though! 202 else { 203 assert.include(props.sectionsClassNames, "show-excerpt"); 204 assert.notInclude(props.sectionsClassNames, "hide-excerpt"); 205 } 206 }); 207 }); 208 209 it("should dispatch SECTION_PERSONALIZATION_UPDATE updates with follow and unfollow", () => { 210 const fakeDate = "2020-01-01T00:00:00.000Z"; 211 sandbox.useFakeTimers(new Date(fakeDate)); 212 const layout = { 213 title: "layout_name", 214 responsiveLayouts: [ 215 { 216 columnCount: 1, 217 tiles: [ 218 { 219 size: "large", 220 position: 0, 221 hasAd: false, 222 hasExcerpt: true, 223 }, 224 { 225 size: "small", 226 position: 2, 227 hasAd: false, 228 hasExcerpt: false, 229 }, 230 { 231 size: "medium", 232 position: 1, 233 hasAd: true, 234 hasExcerpt: true, 235 }, 236 { 237 size: "small", 238 position: 3, 239 hasAd: false, 240 hasExcerpt: false, 241 }, 242 ], 243 }, 244 ], 245 }; 246 // mock the pref for followed section 247 const state = { 248 ...INITIAL_STATE, 249 DiscoveryStream: { 250 ...INITIAL_STATE.DiscoveryStream, 251 sectionPersonalization: { 252 section_key_2: { 253 isFollowed: true, 254 isBlocked: false, 255 }, 256 }, 257 }, 258 Prefs: { 259 ...INITIAL_STATE.Prefs, 260 values: { 261 ...INITIAL_STATE.Prefs.values, 262 [PREF_SECTIONS_PERSONALIZATION_ENABLED]: true, 263 }, 264 }, 265 }; 266 267 wrapper = mount( 268 <WrapWithProvider state={state}> 269 <CardSections 270 dispatch={dispatch} 271 {...DEFAULT_PROPS} 272 data={{ 273 ...DEFAULT_PROPS.data, 274 sections: [ 275 { 276 data: [ 277 { 278 title: "Card 1", 279 image_src: "image1.jpg", 280 url: "http://example.com", 281 }, 282 ], 283 receivedRank: 0, 284 sectionKey: "section_key_1", 285 title: "title", 286 layout, 287 }, 288 { 289 data: [ 290 { 291 title: "Card 2", 292 image_src: "image2.jpg", 293 url: "http://example.com", 294 }, 295 ], 296 receivedRank: 0, 297 sectionKey: "section_key_2", 298 title: "title", 299 layout, 300 }, 301 ], 302 }} 303 /> 304 </WrapWithProvider> 305 ); 306 307 let button = wrapper.find(".section-follow moz-button").first(); 308 button.simulate("click", {}); 309 310 assert.deepEqual(dispatch.getCall(0).firstArg, { 311 type: "SECTION_PERSONALIZATION_SET", 312 data: { 313 section_key_2: { 314 isFollowed: true, 315 isBlocked: false, 316 }, 317 section_key_1: { 318 isFollowed: true, 319 isBlocked: false, 320 followedAt: fakeDate, 321 }, 322 }, 323 meta: { 324 from: "ActivityStream:Content", 325 to: "ActivityStream:Main", 326 }, 327 }); 328 329 assert.calledWith(dispatch.getCall(1), { 330 type: "FOLLOW_SECTION", 331 data: { 332 section: "section_key_1", 333 section_position: 0, 334 event_source: "MOZ_BUTTON", 335 }, 336 meta: { 337 from: "ActivityStream:Content", 338 to: "ActivityStream:Main", 339 skipLocal: true, 340 }, 341 }); 342 343 button = wrapper.find(".section-follow.following moz-button"); 344 button.simulate("click", {}); 345 346 assert.calledWith(dispatch.getCall(2), { 347 type: "SECTION_PERSONALIZATION_SET", 348 data: {}, 349 meta: { 350 from: "ActivityStream:Content", 351 to: "ActivityStream:Main", 352 }, 353 }); 354 355 assert.calledWith(dispatch.getCall(3), { 356 type: "UNFOLLOW_SECTION", 357 data: { 358 section: "section_key_2", 359 section_position: 1, 360 event_source: "MOZ_BUTTON", 361 }, 362 meta: { 363 from: "ActivityStream:Content", 364 to: "ActivityStream:Main", 365 skipLocal: true, 366 }, 367 }); 368 }); 369 370 it("should render <FollowSectionButtonHighlight> when conditions match", () => { 371 const fakeMessageData = { 372 content: { 373 messageType: "FollowSectionButtonHighlight", 374 }, 375 }; 376 377 const layout = { 378 title: "layout_name", 379 responsiveLayouts: [ 380 { 381 columnCount: 1, 382 tiles: [{ size: "large", position: 0, hasExcerpt: true }], 383 }, 384 ], 385 }; 386 387 const state = { 388 ...INITIAL_STATE, 389 DiscoveryStream: { 390 ...INITIAL_STATE.DiscoveryStream, 391 sectionPersonalization: {}, // no sections followed 392 }, 393 Prefs: { 394 ...INITIAL_STATE.Prefs, 395 values: { 396 ...INITIAL_STATE.Prefs.values, 397 [PREF_SECTIONS_PERSONALIZATION_ENABLED]: true, 398 }, 399 }, 400 Messages: { 401 isVisible: true, 402 messageData: fakeMessageData, 403 }, 404 }; 405 406 wrapper = mount( 407 <WrapWithProvider state={state}> 408 <CardSections 409 dispatch={dispatch} 410 {...DEFAULT_PROPS} 411 data={{ 412 ...DEFAULT_PROPS.data, 413 sections: [ 414 { 415 data: [ 416 { 417 title: "Card 1", 418 image_src: "image1.jpg", 419 url: "http://example.com", 420 }, 421 ], 422 receivedRank: 0, 423 sectionKey: "section_key_1", 424 title: "title", 425 layout, 426 }, 427 { 428 data: [ 429 { 430 title: "Card 2", 431 image_src: "image2.jpg", 432 url: "http://example.com", 433 }, 434 ], 435 receivedRank: 0, 436 sectionKey: "section_key_2", 437 title: "title", 438 layout, 439 }, 440 ], 441 }} 442 /> 443 </WrapWithProvider> 444 ); 445 446 // Should only render for the second section (index 1) 447 const highlight = wrapper.find(FollowSectionButtonHighlight); 448 assert.equal(highlight.length, 1); 449 assert.isTrue(wrapper.html().includes("follow-section-button-highlight")); 450 }); 451 452 describe("Keyboard navigation", () => { 453 beforeEach(() => { 454 // Mock window.innerWidth to return a value that will make getActiveColumnLayout return "col-1" 455 Object.defineProperty(window, "innerWidth", { 456 writable: true, 457 configurable: true, 458 value: 500, 459 }); 460 }); 461 462 it("should pass tabIndex={0} to the first card and tabIndex={-1} to other cards", () => { 463 const firstCard = wrapper.find(DSCard).at(0); 464 const secondCard = wrapper.find(DSCard).at(1); 465 const thirdCard = wrapper.find(DSCard).at(2); 466 467 assert.equal(firstCard.prop("tabIndex"), 0); 468 assert.equal(secondCard.prop("tabIndex"), -1); 469 assert.equal(thirdCard.prop("tabIndex"), -1); 470 }); 471 472 it("should update focused index when onFocus is called", () => { 473 const secondCard = wrapper.find(DSCard).at(1); 474 const onFocus = secondCard.prop("onFocus"); 475 476 onFocus(); 477 wrapper.update(); 478 479 assert.equal(wrapper.find(DSCard).at(1).prop("tabIndex"), 0); 480 assert.equal(wrapper.find(DSCard).at(0).prop("tabIndex"), -1); 481 }); 482 483 describe("handleCardKeyDown", () => { 484 let grid; 485 let mockLink; 486 let mockTargetCard; 487 let mockGridElement; 488 let mockCurrentCard; 489 let mockEvent; 490 491 beforeEach(() => { 492 grid = wrapper.find(".ds-section-grid.ds-card-grid"); 493 mockLink = { focus: sandbox.spy() }; 494 mockTargetCard = { 495 querySelector: sandbox.stub().returns(mockLink), 496 }; 497 mockGridElement = { 498 querySelector: sandbox.stub().returns(mockTargetCard), 499 }; 500 mockCurrentCard = { 501 parentElement: mockGridElement, 502 }; 503 mockEvent = { 504 preventDefault: sandbox.spy(), 505 target: { 506 closest: sandbox.stub().returns(mockCurrentCard), 507 }, 508 }; 509 }); 510 511 afterEach(() => { 512 sandbox.restore(); 513 }); 514 515 it("should navigate to next card with ArrowRight", () => { 516 mockEvent.key = "ArrowRight"; 517 mockCurrentCard.classList = ["col-1-position-0"]; 518 519 grid.prop("onKeyDown")(mockEvent); 520 521 assert.calledOnce(mockEvent.preventDefault); 522 assert.calledWith( 523 mockGridElement.querySelector, 524 "article.ds-card.col-1-position-1" 525 ); 526 assert.calledOnce(mockLink.focus); 527 }); 528 529 it("should navigate to previous card with ArrowLeft", () => { 530 mockEvent.key = "ArrowLeft"; 531 mockCurrentCard.classList = ["col-1-position-1"]; 532 533 grid.prop("onKeyDown")(mockEvent); 534 535 assert.calledOnce(mockEvent.preventDefault); 536 assert.calledWith( 537 mockGridElement.querySelector, 538 "article.ds-card.col-1-position-0" 539 ); 540 assert.calledOnce(mockLink.focus); 541 }); 542 }); 543 }); 544 });