Restructure the way we render the feed + add billboard locations on signed in feed (#19533)
* feat: fix of there is no article * feat: set the feed order in a data structure rather than in a view * feat: update yarn.lock * feat: push the items to the array * feat: update feed to only show billboards if we have the correct length of items in the feed * fix: organizedFeedItems.legth * feat: rename 'featured' to 'image' * feat: rename 'featured' to 'image' * feat: rename 'featured' to 'image' * feat: add some utilities for the feed that we can use in the FeedTest * feat/WIP: the setup for the feed tests and a first working test * test: imageItem * fix: the podcasts can be passed through as an array of objects in the feedItems so that they can be grouped in one card * feat: remove podcastEpisode state and the attribute in the object * fix: check that the items exist before trying to slice them * feat: setup the userdata and the podcast data in the test * whoops - commit the podcast episodes * feat: write soem more tests for the feed and including the podcasts * feat: add some more tests * feat: add more billboards tests to chcek the order of stuff * feat: set the timeframe not empty * feat: update the logic for organizaed feed by inserting the last on first * doc: jsdoc for functions * refactor: break the code up into smaller functions * refactor: make the code more readable and easier to follow * refactor: pull function out into a utlity * feat: add specs to utility * feat: chcek if pinned post * test the latest timeframe correctly * chore: update var name * move the podcast items out of the object clause * feat: update text * feat: add a pack file that duplicates initializeDisplayAdVisibility * feat: create callbacks that will help us to determine when the feed has been rendered so that we can observe the dsplay ads accordingly * chore: rename to billboards instead of display ad
This commit is contained in:
parent
e5c7c8cecf
commit
b98dbd4035
11 changed files with 1400 additions and 139 deletions
|
|
@ -15,7 +15,7 @@ class DisplayAdsController < ApplicationController
|
|||
area: params[:placement_area],
|
||||
user_signed_in: user_signed_in?,
|
||||
user_id: current_user&.id,
|
||||
article: ArticleDecorator.new(@article),
|
||||
article: @article ? ArticleDecorator.new(@article) : nil,
|
||||
)
|
||||
|
||||
if @display_ad && !session_current_user_id
|
||||
|
|
|
|||
|
|
@ -3,92 +3,248 @@ import { useEffect, useState } from 'preact/hooks';
|
|||
import PropTypes from 'prop-types';
|
||||
import { useListNavigation } from '../shared/components/useListNavigation';
|
||||
import { useKeyboardShortcuts } from '../shared/components/useKeyboardShortcuts';
|
||||
import { insertInArrayIf } from '../../javascript/utilities/insertInArrayIf';
|
||||
|
||||
/* global userData sendHapticMessage showLoginModal buttonFormData renderNewSidebarCount */
|
||||
|
||||
export const Feed = ({ timeFrame, renderFeed }) => {
|
||||
export const Feed = ({ timeFrame, renderFeed, afterRender }) => {
|
||||
const { reading_list_ids = [] } = userData(); // eslint-disable-line camelcase
|
||||
const [bookmarkedFeedItems, setBookmarkedFeedItems] = useState(
|
||||
new Set(reading_list_ids),
|
||||
);
|
||||
const [pinnedArticle, setPinnedArticle] = useState(null);
|
||||
const [pinnedItem, setPinnedItem] = useState(null);
|
||||
const [imageItem, setimageItem] = useState(null);
|
||||
const [feedItems, setFeedItems] = useState([]);
|
||||
const [podcastEpisodes, setPodcastEpisodes] = useState([]);
|
||||
const [onError, setOnError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setPodcastEpisodes(getPodcastEpisodes());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchFeedItems = async () => {
|
||||
const organizeFeedItems = async () => {
|
||||
try {
|
||||
if (onError) setOnError(false);
|
||||
|
||||
let feedItems = await getFeedItems(timeFrame);
|
||||
fetchFeedItems(timeFrame).then(
|
||||
([
|
||||
feedPosts,
|
||||
feedFirstBillboard,
|
||||
feedSecondBillboard,
|
||||
feedThirdBillboard,
|
||||
]) => {
|
||||
const imagePost = getImagePost(feedPosts);
|
||||
const pinnedPost = getPinnedPost(feedPosts);
|
||||
const podcastPost = getPodcastEpisodes();
|
||||
|
||||
// Here we extract from the feed two special items: pinned and featured
|
||||
const hasSetPinnedPost = setPinnedPostItem(pinnedPost, imagePost);
|
||||
const hasSetImagePostItem = setImagePostItem(imagePost);
|
||||
|
||||
const pinnedArticle = feedItems.find((story) => story.pinned === true);
|
||||
const updatedFeedPosts = updateFeedPosts(
|
||||
feedPosts,
|
||||
imagePost,
|
||||
pinnedPost,
|
||||
);
|
||||
|
||||
// Ensure first article is one with a main_image
|
||||
// This is important because the featuredStory will
|
||||
// appear at the top of the feed, with a larger
|
||||
// main_image than any of the stories or feed elements.
|
||||
const featuredStory = feedItems.find(
|
||||
(story) => story.main_image !== null,
|
||||
// We implement the following organization for the feed:
|
||||
// 1. Place the pinned post first (if the timeframe is relevant)
|
||||
// 2. Place the image post next
|
||||
// 3. If you follow podcasts, place the podcast episodes that are
|
||||
// published today (this is an array)
|
||||
// 4. Place the rest of the stories for the feed
|
||||
// 5. Insert the billboards in that array accordingly
|
||||
// - feed_first: Before all home page posts
|
||||
// - feed_second: Between 2nd and 3rd posts in the feed
|
||||
// - feed_third: Between 7th and 8th posts in the feed
|
||||
|
||||
const organizedFeedItems = [
|
||||
...insertInArrayIf(hasSetPinnedPost, pinnedPost),
|
||||
...insertInArrayIf(hasSetImagePostItem, imagePost),
|
||||
...insertInArrayIf(podcastPost.length > 0, podcastPost),
|
||||
...updatedFeedPosts,
|
||||
];
|
||||
|
||||
const organizedFeedItemsWithBillboards = insertBillboardsInFeed(
|
||||
organizedFeedItems,
|
||||
feedFirstBillboard,
|
||||
feedSecondBillboard,
|
||||
feedThirdBillboard,
|
||||
);
|
||||
|
||||
setFeedItems(organizedFeedItemsWithBillboards);
|
||||
},
|
||||
);
|
||||
|
||||
// If pinned and featured article aren't the same,
|
||||
// (either because featuredStory is missing or because they represent two different articles),
|
||||
// we set the pinnedArticle and remove it from feedItems.
|
||||
// If pinned and featured are the same, we just remove it from feedItems without setting it as state.
|
||||
// NB: We only show the pinned post on the "Relevant" feed (when there is no 'timeFrame' selected)
|
||||
if (pinnedArticle && timeFrame === '') {
|
||||
feedItems = feedItems.filter((item) => item.id !== pinnedArticle.id);
|
||||
|
||||
if (pinnedArticle.id !== featuredStory?.id) {
|
||||
setPinnedArticle(pinnedArticle);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove that first story from the array to
|
||||
// prevent it from rendering twice in the feed.
|
||||
const featuredIndex = feedItems.indexOf(featuredStory);
|
||||
if (featuredStory) {
|
||||
feedItems.splice(featuredIndex, 1);
|
||||
}
|
||||
const organizedFeedItems = [featuredStory, feedItems].flat();
|
||||
|
||||
setFeedItems(organizedFeedItems);
|
||||
} catch {
|
||||
if (!onError) setOnError(true);
|
||||
}
|
||||
};
|
||||
|
||||
fetchFeedItems();
|
||||
organizeFeedItems();
|
||||
}, [timeFrame, onError]);
|
||||
|
||||
/**
|
||||
* Retrieves feed data.
|
||||
*
|
||||
* @param {number} [page=1] Page of feed data to retrieve
|
||||
*
|
||||
* @returns {Promise} A promise containing the JSON response for the feed data.
|
||||
*/
|
||||
async function getFeedItems(timeFrame = '', page = 1) {
|
||||
const response = await fetch(`/stories/feed/${timeFrame}?page=${page}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-Token': window.csrfToken,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
return await response.json();
|
||||
useEffect(() => {
|
||||
if (feedItems.length > 0) {
|
||||
afterRender();
|
||||
}
|
||||
}, [feedItems.length]);
|
||||
// /**
|
||||
// * Retrieves the imagePost which will later appear at the top of the feed,
|
||||
// * with a larger main_image than any of the stories or feed elements.
|
||||
// *
|
||||
// * @param {Array} The original feed posts that are retrieved from the endpoint.
|
||||
// *
|
||||
// * @returns {Object} The first post with a main_image
|
||||
// */
|
||||
function getImagePost(feedPosts) {
|
||||
return feedPosts.find((post) => post.main_image !== null);
|
||||
}
|
||||
|
||||
// /**
|
||||
// * Retrieves the pinnedPost which will later appear at the top the feed with a pin.
|
||||
// *
|
||||
// * @param {Array} The original feed posts that are retrieved from the endpoint.
|
||||
// *
|
||||
// * @returns {Object} The first post that has pinned set to true
|
||||
// */
|
||||
function getPinnedPost(feedPosts) {
|
||||
return feedPosts.find((post) => post.pinned === true);
|
||||
}
|
||||
|
||||
// /**
|
||||
// * Sets the Pinned Item into state.
|
||||
// *
|
||||
// * @param {Object} The pinnedPost
|
||||
// * @param {Object} The imagePost
|
||||
// *
|
||||
// * @returns {boolean} If we set the pinned post we return true else we return false
|
||||
// */
|
||||
function setPinnedPostItem(pinnedPost, imagePost) {
|
||||
// We only show the pinned post on the "Relevant" feed (when there is no 'timeFrame' selected)
|
||||
if (!pinnedPost || timeFrame !== '') return false;
|
||||
|
||||
// If the pinned and the image post aren't the same, (either because imagePost is missing or
|
||||
// because they represent two different posts), we set the pinnedPost
|
||||
if (pinnedPost.id !== imagePost?.id) {
|
||||
setPinnedItem(pinnedPost);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// /**
|
||||
// * Sets the Image Item into state.
|
||||
// *
|
||||
// * @param {Object} The imagePost
|
||||
// *
|
||||
// * @returns {boolean} If we set the pinned post we return true
|
||||
// */
|
||||
function setImagePostItem(imagePost) {
|
||||
if (imagePost) {
|
||||
setimageItem(imagePost);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// /**
|
||||
// * Updates the feedPosts to remove the relevant items like the pinned
|
||||
// * post and the image post that will be added to the top of final organized feed
|
||||
// * items separately. We do not want duplication.
|
||||
// *
|
||||
// * @param {Array} The original feed posts that are retrieved from the endpoint.
|
||||
// * @param {Object} The imagePost
|
||||
// * @param {Object} The pinnedPost
|
||||
// *
|
||||
// * @returns {Array} We return the new array that no longer contains the pinned post or the image post.
|
||||
// */
|
||||
function updateFeedPosts(feedPosts, imagePost, pinnedPost) {
|
||||
let filteredFeedPost = feedPosts;
|
||||
if (pinnedPost) {
|
||||
filteredFeedPost = feedPosts.filter((item) => item.id !== pinnedPost.id);
|
||||
}
|
||||
|
||||
if (imagePost) {
|
||||
const imagePostIndex = filteredFeedPost.indexOf(imagePost);
|
||||
filteredFeedPost.splice(imagePostIndex, 1);
|
||||
}
|
||||
|
||||
return filteredFeedPost;
|
||||
}
|
||||
|
||||
// /**
|
||||
// * Inserts the billboards (if they exist) into the feed.
|
||||
// *
|
||||
// * @param {organizedFeedItems} The partially organized feed items.
|
||||
// * @param {String} feedFirstBillboard is the feed_first billboard retrieved from an endpoint.
|
||||
// * @param {String} feedSecondBillboard is the feed_second billboard retrieved from an endpoint.
|
||||
// * @param {String} feedThirdBillboard is the feed_third billboard retrieved from an endpoint.
|
||||
// *
|
||||
// * @returns {Array} We return the array containing the billboards slotted into the correct positions.
|
||||
// */
|
||||
function insertBillboardsInFeed(
|
||||
organizedFeedItems,
|
||||
feedFirstBillboard,
|
||||
feedSecondBillboard,
|
||||
feedThirdBillboard,
|
||||
) {
|
||||
if (organizedFeedItems.length >= 9 && feedThirdBillboard) {
|
||||
organizedFeedItems.splice(7, 0, feedThirdBillboard);
|
||||
}
|
||||
|
||||
if (organizedFeedItems.length >= 3 && feedSecondBillboard) {
|
||||
organizedFeedItems.splice(2, 0, feedSecondBillboard);
|
||||
}
|
||||
|
||||
if (organizedFeedItems.length >= 0 && feedFirstBillboard) {
|
||||
organizedFeedItems.splice(0, 0, feedFirstBillboard);
|
||||
}
|
||||
|
||||
return organizedFeedItems;
|
||||
}
|
||||
|
||||
// /**
|
||||
// * Retrieves data for the feed. The data will include articles and billboards.
|
||||
// *
|
||||
// * @param {number} [page=1] Page of feed data to retrieve
|
||||
// * @param {string} The time frame of feed data to retrieve
|
||||
// *
|
||||
// * @returns {Promise} A promise containing the JSON response for the feed data.
|
||||
// */
|
||||
async function fetchFeedItems(timeFrame = '', page = 1) {
|
||||
const [
|
||||
feedPostsResponse,
|
||||
feedFirstBillboardResponse,
|
||||
feedSecondBillboardResponse,
|
||||
feedThirdBillboardResponse,
|
||||
] = await Promise.all([
|
||||
fetch(`/stories/feed/${timeFrame}?page=${page}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-Token': window.csrfToken,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
}),
|
||||
fetch(`/display_ads/feed_first`),
|
||||
fetch(`/display_ads/feed_second`),
|
||||
fetch(`/display_ads/feed_third`),
|
||||
]);
|
||||
|
||||
const feedPosts = await feedPostsResponse.json();
|
||||
const feedFirstBillboard = await feedFirstBillboardResponse.text();
|
||||
const feedSecondBillboard = await feedSecondBillboardResponse.text();
|
||||
const feedThirdBillboard = await feedThirdBillboardResponse.text();
|
||||
|
||||
return [
|
||||
feedPosts,
|
||||
feedFirstBillboard,
|
||||
feedSecondBillboard,
|
||||
feedThirdBillboard,
|
||||
];
|
||||
}
|
||||
|
||||
// /**
|
||||
// * Retrieves the podcasts for the feed from the user data and the `followed-podcasts`
|
||||
// * div item.
|
||||
// *
|
||||
// * @returns {Object} An Object containing today's podcast episodes for the podcasts found in followed_podcast_ids.
|
||||
// */
|
||||
function getPodcastEpisodes() {
|
||||
const el = document.getElementById('followed-podcasts');
|
||||
const user = userData(); // Global
|
||||
|
|
@ -109,7 +265,8 @@ export const Feed = ({ timeFrame, renderFeed }) => {
|
|||
}
|
||||
|
||||
/**
|
||||
* Dispatches a click event to bookmark/unbookmark an article.
|
||||
* Dispatches a click event to bookmark/unbookmark an article and sets the ID's of the
|
||||
* updated bookmark feed items.
|
||||
*
|
||||
* @param {Event} event
|
||||
*/
|
||||
|
|
@ -182,9 +339,9 @@ export const Feed = ({ timeFrame, renderFeed }) => {
|
|||
</div>
|
||||
) : (
|
||||
renderFeed({
|
||||
pinnedArticle,
|
||||
pinnedItem,
|
||||
imageItem,
|
||||
feedItems,
|
||||
podcastEpisodes,
|
||||
bookmarkedFeedItems,
|
||||
bookmarkClick,
|
||||
})
|
||||
|
|
|
|||
226
app/javascript/articles/__tests__/Feed.test.jsx
Normal file
226
app/javascript/articles/__tests__/Feed.test.jsx
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
/* eslint-disable no-irregular-whitespace */
|
||||
import { h } from 'preact';
|
||||
import { render, waitFor } from '@testing-library/preact';
|
||||
import fetch from 'jest-fetch-mock';
|
||||
import '@testing-library/jest-dom';
|
||||
import { Feed } from '../Feed';
|
||||
import {
|
||||
feedPosts,
|
||||
feedPostsWherePinnedAndImagePostsSame,
|
||||
firstBillboard,
|
||||
secondBillboard,
|
||||
thirdBillboard,
|
||||
podcastEpisodes,
|
||||
} from './utilities/feedUtilities';
|
||||
import '../../../assets/javascripts/lib/xss';
|
||||
import '../../../assets/javascripts/utilities/timeAgo';
|
||||
|
||||
global.fetch = fetch;
|
||||
|
||||
describe('<Feed /> component', () => {
|
||||
const getUserData = () => {
|
||||
return {
|
||||
followed_tag_names: ['javascript'],
|
||||
followed_podcast_ids: [1], //should we make this dynamic
|
||||
profile_image_90: 'mock_url_link',
|
||||
name: 'firstname lastname',
|
||||
username: 'username',
|
||||
reading_list_ids: [],
|
||||
};
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
global.userData = jest.fn(() => getUserData());
|
||||
document.body.setAttribute('data-user', JSON.stringify(getUserData()));
|
||||
|
||||
const node = document.createElement('div');
|
||||
node.setAttribute('id', 'followed-podcasts');
|
||||
node.setAttribute('data-episodes', JSON.stringify(podcastEpisodes));
|
||||
document.body.appendChild(node);
|
||||
});
|
||||
|
||||
describe('feedItem organization', () => {
|
||||
let callback;
|
||||
beforeAll(() => {
|
||||
fetch.mockResponseOnce(JSON.stringify(feedPosts));
|
||||
fetch.mockResponseOnce(firstBillboard);
|
||||
fetch.mockResponseOnce(secondBillboard);
|
||||
fetch.mockResponseOnce(thirdBillboard);
|
||||
|
||||
callback = jest.fn();
|
||||
render(<Feed timeFrame="" renderFeed={callback} />);
|
||||
});
|
||||
|
||||
it('should return the correct length of feedItems', async () => {
|
||||
await waitFor(() => {
|
||||
const lastCallbackResult =
|
||||
callback.mock.calls[callback.mock.calls.length - 1][0];
|
||||
expect(lastCallbackResult.feedItems.length).toEqual(14);
|
||||
});
|
||||
});
|
||||
|
||||
it('should set the pinnedItem and place it correctly in the feed', async () => {
|
||||
await waitFor(() => {
|
||||
const lastCallbackResult =
|
||||
callback.mock.calls[callback.mock.calls.length - 1][0];
|
||||
const firstPinnedItem = feedPosts.find((o) => o.pinned === true);
|
||||
expect(lastCallbackResult.pinnedItem).toEqual(firstPinnedItem);
|
||||
expect(lastCallbackResult.feedItems[1]).toEqual(firstPinnedItem);
|
||||
});
|
||||
});
|
||||
|
||||
it('should set the imageItem and place it correctly in the feed', async () => {
|
||||
await waitFor(() => {
|
||||
const lastCallbackResult =
|
||||
callback.mock.calls[callback.mock.calls.length - 1][0];
|
||||
const firstImageItem = feedPosts.find(
|
||||
(post) => post.main_image !== null,
|
||||
);
|
||||
expect(lastCallbackResult.imageItem).toEqual(firstImageItem);
|
||||
expect(lastCallbackResult.feedItems[2]).toEqual(firstImageItem);
|
||||
});
|
||||
});
|
||||
|
||||
it('should place the billboards correctly within the feedItems', async () => {
|
||||
await waitFor(() => {
|
||||
const lastCallbackResult =
|
||||
callback.mock.calls[callback.mock.calls.length - 1][0];
|
||||
expect(lastCallbackResult.feedItems[0]).toEqual(firstBillboard);
|
||||
expect(lastCallbackResult.feedItems[3]).toEqual(secondBillboard);
|
||||
expect(lastCallbackResult.feedItems[9]).toEqual(thirdBillboard);
|
||||
});
|
||||
});
|
||||
|
||||
it('should place the podcasts correctly within feedItems', async () => {
|
||||
await waitFor(() => {
|
||||
const lastCallbackResult =
|
||||
callback.mock.calls[callback.mock.calls.length - 1][0];
|
||||
expect(lastCallbackResult.feedItems[4]).toEqual(podcastEpisodes);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when pinned and image posts are the same', () => {
|
||||
let callback;
|
||||
beforeAll(() => {
|
||||
fetch.mockResponseOnce(
|
||||
JSON.stringify(feedPostsWherePinnedAndImagePostsSame),
|
||||
);
|
||||
fetch.mockResponseOnce(firstBillboard);
|
||||
fetch.mockResponseOnce(secondBillboard);
|
||||
fetch.mockResponseOnce(thirdBillboard);
|
||||
|
||||
callback = jest.fn();
|
||||
render(<Feed timeFrame="" renderFeed={callback} />);
|
||||
});
|
||||
|
||||
it('should not set a pinned item', async () => {
|
||||
const lastCallbackResult =
|
||||
callback.mock.calls[callback.mock.calls.length - 1][0];
|
||||
const postAndImageItem = feedPostsWherePinnedAndImagePostsSame.find(
|
||||
(post) => post.main_image !== null && post.pinned === true,
|
||||
);
|
||||
|
||||
expect(lastCallbackResult.pinnedItem).toEqual(null);
|
||||
expect(lastCallbackResult.imageItem).toEqual(postAndImageItem);
|
||||
expect(lastCallbackResult.feedItems[1]).toEqual(postAndImageItem);
|
||||
expect(lastCallbackResult.feedItems[2]).not.toEqual(postAndImageItem);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when the timeframe prop is 'latest'", () => {
|
||||
let callback;
|
||||
beforeAll(() => {
|
||||
fetch.mockResponseOnce(JSON.stringify(feedPosts));
|
||||
fetch.mockResponseOnce(firstBillboard);
|
||||
fetch.mockResponseOnce(secondBillboard);
|
||||
fetch.mockResponseOnce(thirdBillboard);
|
||||
|
||||
callback = jest.fn();
|
||||
render(<Feed timeFrame="latest" renderFeed={callback} />);
|
||||
});
|
||||
|
||||
it('should not set the pinned items', async () => {
|
||||
const lastCallbackResult =
|
||||
callback.mock.calls[callback.mock.calls.length - 1][0];
|
||||
expect(lastCallbackResult.pinnedItem).toEqual(null);
|
||||
});
|
||||
|
||||
it('should return the correct length of feedItems (by excluding pinned item)', async () => {
|
||||
await waitFor(() => {
|
||||
const lastCallbackResult =
|
||||
callback.mock.calls[callback.mock.calls.length - 1][0];
|
||||
expect(lastCallbackResult.feedItems.length).toEqual(13);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("when we there isn't all three billboards on the home feed", () => {
|
||||
describe("when there isn't a feed_second billboard", () => {
|
||||
let callback;
|
||||
beforeAll(() => {
|
||||
fetch.mockResponseOnce(JSON.stringify(feedPosts));
|
||||
fetch.mockResponseOnce(firstBillboard);
|
||||
fetch.mockResponseOnce(undefined);
|
||||
fetch.mockResponseOnce(thirdBillboard);
|
||||
|
||||
callback = jest.fn();
|
||||
render(<Feed timeFrame="" renderFeed={callback} />);
|
||||
});
|
||||
|
||||
it('should return the correct length of feedItems', async () => {
|
||||
await waitFor(() => {
|
||||
const lastCallbackResult =
|
||||
callback.mock.calls[callback.mock.calls.length - 1][0];
|
||||
expect(lastCallbackResult.feedItems.length).toEqual(13);
|
||||
});
|
||||
});
|
||||
|
||||
it('should still amend the organization of the feedItems correctly', async () => {
|
||||
await waitFor(() => {
|
||||
const lastCallbackResult =
|
||||
callback.mock.calls[callback.mock.calls.length - 1][0];
|
||||
expect(lastCallbackResult.feedItems[0]).toEqual(firstBillboard);
|
||||
// there is no second bilboard so podcasts get rendered in 4th place
|
||||
expect(lastCallbackResult.feedItems[3]).toEqual(podcastEpisodes);
|
||||
expect(lastCallbackResult.feedItems[8]).toEqual(thirdBillboard);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("when there isn't a feed_first or feed_second billboard", () => {
|
||||
let callback;
|
||||
beforeAll(() => {
|
||||
fetch.mockResponseOnce(JSON.stringify(feedPosts));
|
||||
fetch.mockResponseOnce(undefined);
|
||||
fetch.mockResponseOnce(undefined);
|
||||
fetch.mockResponseOnce(thirdBillboard);
|
||||
|
||||
callback = jest.fn();
|
||||
render(<Feed timeFrame="" renderFeed={callback} />);
|
||||
});
|
||||
|
||||
it('should return the correct length of feedItems', async () => {
|
||||
await waitFor(() => {
|
||||
const lastCallbackResult =
|
||||
callback.mock.calls[callback.mock.calls.length - 1][0];
|
||||
expect(lastCallbackResult.feedItems.length).toEqual(12);
|
||||
});
|
||||
});
|
||||
|
||||
it('should still amend the organization of the feedItems correctly', async () => {
|
||||
await waitFor(() => {
|
||||
const lastCallbackResult =
|
||||
callback.mock.calls[callback.mock.calls.length - 1][0];
|
||||
|
||||
const pinnedItem = feedPosts.find((o) => o.pinned === true);
|
||||
// there is no first billboard
|
||||
expect(lastCallbackResult.feedItems[0]).toEqual(pinnedItem);
|
||||
// there is no second bilboard so podcsats get rendered in 3rd place
|
||||
expect(lastCallbackResult.feedItems[2]).toEqual(podcastEpisodes);
|
||||
expect(lastCallbackResult.feedItems[7]).toEqual(thirdBillboard);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
685
app/javascript/articles/__tests__/utilities/feedUtilities.js
Normal file
685
app/javascript/articles/__tests__/utilities/feedUtilities.js
Normal file
|
|
@ -0,0 +1,685 @@
|
|||
export const assetPath = (relativeUrl) => `/images/${relativeUrl}`;
|
||||
|
||||
export const feedPosts = [
|
||||
{
|
||||
title: 'That Good Night Et molestias',
|
||||
path: '/douglas_esmeralda/that-good-night-et-molestias-5fak',
|
||||
id: 85,
|
||||
user_id: 9,
|
||||
comments_count: 0,
|
||||
public_reactions_count: 3,
|
||||
organization_id: null,
|
||||
reading_time: 1,
|
||||
video_thumbnail_url: null,
|
||||
video: null,
|
||||
video_duration_in_minutes: '00:00',
|
||||
experience_level_rating: 5,
|
||||
experience_level_rating_distribution: 5,
|
||||
user: {
|
||||
name: 'Esmeralda "The Esmeralda" Douglas \\:/',
|
||||
username: 'douglas_esmeralda',
|
||||
slug: 'douglas_esmeralda',
|
||||
profile_image_90:
|
||||
'/uploads/user/profile_image/9/a80aa112-0bbc-4c25-9535-3a21d0cd56a0.png',
|
||||
profile_image_url:
|
||||
'/uploads/user/profile_image/9/a80aa112-0bbc-4c25-9535-3a21d0cd56a0.png',
|
||||
},
|
||||
pinned: false,
|
||||
main_image: 'https://pigment.github.io/fake-logos/logos/medium/color/1.png',
|
||||
tag_list: ['performance', 'be', 'javascript'],
|
||||
readable_publish_date: 'May 15',
|
||||
flare_tag: null,
|
||||
class_name: 'Article',
|
||||
cloudinary_video_url: null,
|
||||
published_at_int: 1684156003,
|
||||
published_timestamp: '2023-05-15T13:06:43Z',
|
||||
main_image_background_hex_color: '#dddddd',
|
||||
public_reaction_categories: [
|
||||
{
|
||||
slug: 'like',
|
||||
name: 'Like',
|
||||
icon: 'sparkle-heart',
|
||||
position: 1,
|
||||
},
|
||||
{
|
||||
slug: 'unicorn',
|
||||
name: 'Unicorn',
|
||||
icon: 'multi-unicorn',
|
||||
position: 2,
|
||||
},
|
||||
],
|
||||
top_comments: [],
|
||||
},
|
||||
{
|
||||
title: 'After Many a Summer Dies the Swan Qui pariatur',
|
||||
path: '/tyler_blanda/after-many-a-summer-dies-the-swan-qui-pariatur-3o0p',
|
||||
id: 142,
|
||||
user_id: 6,
|
||||
comments_count: 0,
|
||||
public_reactions_count: 3,
|
||||
organization_id: null,
|
||||
reading_time: 1,
|
||||
video_thumbnail_url: null,
|
||||
video: null,
|
||||
video_duration_in_minutes: '00:00',
|
||||
experience_level_rating: 5,
|
||||
experience_level_rating_distribution: 5,
|
||||
user: {
|
||||
name: 'Tyler "The Tyler" Blanda \\:/',
|
||||
username: 'tyler_blanda',
|
||||
slug: 'tyler_blanda',
|
||||
profile_image_90:
|
||||
'/uploads/user/profile_image/6/3cc1898b-cd2a-4ec2-a379-e0c9102a2262.png',
|
||||
profile_image_url:
|
||||
'/uploads/user/profile_image/6/3cc1898b-cd2a-4ec2-a379-e0c9102a2262.png',
|
||||
},
|
||||
pinned: false,
|
||||
main_image:
|
||||
'https://pigment.github.io/fake-logos/logos/medium/color/12.png',
|
||||
tag_list: [],
|
||||
readable_publish_date: 'May 15',
|
||||
flare_tag: null,
|
||||
class_name: 'Article',
|
||||
cloudinary_video_url: null,
|
||||
published_at_int: 1684156197,
|
||||
published_timestamp: '2023-05-15T13:09:57Z',
|
||||
main_image_background_hex_color: '#dddddd',
|
||||
public_reaction_categories: [
|
||||
{
|
||||
slug: 'like',
|
||||
name: 'Like',
|
||||
icon: 'sparkle-heart',
|
||||
position: 1,
|
||||
},
|
||||
{
|
||||
slug: 'unicorn',
|
||||
name: 'Unicorn',
|
||||
icon: 'multi-unicorn',
|
||||
position: 2,
|
||||
},
|
||||
],
|
||||
top_comments: [],
|
||||
},
|
||||
{
|
||||
title: 'Number the Stars Et ab',
|
||||
path: '/schroeder_irvin/number-the-stars-et-ab-18p4',
|
||||
id: 143,
|
||||
user_id: 2,
|
||||
comments_count: 0,
|
||||
public_reactions_count: 4,
|
||||
organization_id: null,
|
||||
reading_time: 1,
|
||||
video_thumbnail_url: null,
|
||||
video: null,
|
||||
video_duration_in_minutes: '00:00',
|
||||
experience_level_rating: 5,
|
||||
experience_level_rating_distribution: 5,
|
||||
user: {
|
||||
name: 'Irvin "The Irvin" Schroeder \\:/',
|
||||
username: 'schroeder_irvin',
|
||||
slug: 'schroeder_irvin',
|
||||
profile_image_90:
|
||||
'/uploads/user/profile_image/2/8eb14853-857f-4502-a9fd-507b27c6c300.png',
|
||||
profile_image_url:
|
||||
'/uploads/user/profile_image/2/8eb14853-857f-4502-a9fd-507b27c6c300.png',
|
||||
},
|
||||
pinned: false,
|
||||
main_image: 'https://pigment.github.io/fake-logos/logos/medium/color/7.png',
|
||||
tag_list: [],
|
||||
readable_publish_date: 'May 15',
|
||||
flare_tag: null,
|
||||
class_name: 'Article',
|
||||
cloudinary_video_url: null,
|
||||
published_at_int: 1684156197,
|
||||
published_timestamp: '2023-05-15T13:09:57Z',
|
||||
main_image_background_hex_color: '#dddddd',
|
||||
public_reaction_categories: [
|
||||
{
|
||||
slug: 'like',
|
||||
name: 'Like',
|
||||
icon: 'sparkle-heart',
|
||||
position: 1,
|
||||
},
|
||||
{
|
||||
slug: 'unicorn',
|
||||
name: 'Unicorn',
|
||||
icon: 'multi-unicorn',
|
||||
position: 2,
|
||||
},
|
||||
],
|
||||
top_comments: [],
|
||||
},
|
||||
{
|
||||
title: 'Now Sleeps the Crimson Petal Nihil aut',
|
||||
path: '/schroeder_irvin/now-sleeps-the-crimson-petal-nihil-aut-a8o',
|
||||
id: 141,
|
||||
user_id: 2,
|
||||
comments_count: 0,
|
||||
public_reactions_count: 4,
|
||||
organization_id: null,
|
||||
reading_time: 1,
|
||||
video_thumbnail_url: null,
|
||||
video: null,
|
||||
video_duration_in_minutes: '00:00',
|
||||
experience_level_rating: 5,
|
||||
experience_level_rating_distribution: 5,
|
||||
user: {
|
||||
name: 'Irvin "The Irvin" Schroeder \\:/',
|
||||
username: 'schroeder_irvin',
|
||||
slug: 'schroeder_irvin',
|
||||
profile_image_90:
|
||||
'/uploads/user/profile_image/2/8eb14853-857f-4502-a9fd-507b27c6c300.png',
|
||||
profile_image_url:
|
||||
'/uploads/user/profile_image/2/8eb14853-857f-4502-a9fd-507b27c6c300.png',
|
||||
},
|
||||
pinned: true,
|
||||
main_image: 'https://pigment.github.io/fake-logos/logos/medium/color/6.png',
|
||||
tag_list: [],
|
||||
readable_publish_date: 'May 15',
|
||||
flare_tag: null,
|
||||
class_name: 'Article',
|
||||
cloudinary_video_url: null,
|
||||
published_at_int: 1684156197,
|
||||
published_timestamp: '2023-05-15T13:09:57Z',
|
||||
main_image_background_hex_color: '#dddddd',
|
||||
public_reaction_categories: [
|
||||
{
|
||||
slug: 'like',
|
||||
name: 'Like',
|
||||
icon: 'sparkle-heart',
|
||||
position: 1,
|
||||
},
|
||||
{
|
||||
slug: 'unicorn',
|
||||
name: 'Unicorn',
|
||||
icon: 'multi-unicorn',
|
||||
position: 2,
|
||||
},
|
||||
],
|
||||
top_comments: [],
|
||||
},
|
||||
{
|
||||
title: 'Dying of the Light Et libero',
|
||||
path: '/thiel_erminia/dying-of-the-light-et-libero-2b70',
|
||||
id: 151,
|
||||
user_id: 68,
|
||||
comments_count: 0,
|
||||
public_reactions_count: 3,
|
||||
organization_id: null,
|
||||
reading_time: 1,
|
||||
video_thumbnail_url: null,
|
||||
video: null,
|
||||
video_duration_in_minutes: '00:00',
|
||||
experience_level_rating: 5,
|
||||
experience_level_rating_distribution: 5,
|
||||
user: {
|
||||
name: 'Erminia "The Erminia" Thiel \\:/',
|
||||
username: 'thiel_erminia',
|
||||
slug: 'thiel_erminia',
|
||||
profile_image_90:
|
||||
'/uploads/user/profile_image/68/bb57f764-9b55-475f-b54e-4135bff64379.png',
|
||||
profile_image_url:
|
||||
'/uploads/user/profile_image/68/bb57f764-9b55-475f-b54e-4135bff64379.png',
|
||||
},
|
||||
pinned: false,
|
||||
main_image: 'https://pigment.github.io/fake-logos/logos/medium/color/6.png',
|
||||
tag_list: [],
|
||||
readable_publish_date: 'May 15',
|
||||
flare_tag: null,
|
||||
class_name: 'Article',
|
||||
cloudinary_video_url: null,
|
||||
published_at_int: 1684156198,
|
||||
published_timestamp: '2023-05-15T13:09:58Z',
|
||||
main_image_background_hex_color: '#dddddd',
|
||||
public_reaction_categories: [
|
||||
{
|
||||
slug: 'like',
|
||||
name: 'Like',
|
||||
icon: 'sparkle-heart',
|
||||
position: 1,
|
||||
},
|
||||
],
|
||||
top_comments: [],
|
||||
},
|
||||
{
|
||||
title: 'A Confederacy of Dunces Ut et',
|
||||
path: '/macgyver_anamaria/a-confederacy-of-dunces-ut-et-5618',
|
||||
id: 107,
|
||||
user_id: 69,
|
||||
comments_count: 0,
|
||||
public_reactions_count: 4,
|
||||
organization_id: null,
|
||||
reading_time: 1,
|
||||
video_thumbnail_url: null,
|
||||
video: null,
|
||||
video_duration_in_minutes: '00:00',
|
||||
experience_level_rating: 5,
|
||||
experience_level_rating_distribution: 5,
|
||||
user: {
|
||||
name: 'Anamaria "The Anamaria" MacGyver \\:/',
|
||||
username: 'macgyver_anamaria',
|
||||
slug: 'macgyver_anamaria',
|
||||
profile_image_90:
|
||||
'/uploads/user/profile_image/69/10f094e2-707a-483a-9e6f-ca2ca59c5ac3.png',
|
||||
profile_image_url:
|
||||
'/uploads/user/profile_image/69/10f094e2-707a-483a-9e6f-ca2ca59c5ac3.png',
|
||||
},
|
||||
pinned: false,
|
||||
main_image: 'https://pigment.github.io/fake-logos/logos/medium/color/5.png',
|
||||
tag_list: [],
|
||||
readable_publish_date: 'May 15',
|
||||
flare_tag: null,
|
||||
class_name: 'Article',
|
||||
cloudinary_video_url: null,
|
||||
published_at_int: 1684156194,
|
||||
published_timestamp: '2023-05-15T13:09:54Z',
|
||||
main_image_background_hex_color: '#dddddd',
|
||||
public_reaction_categories: [
|
||||
{
|
||||
slug: 'like',
|
||||
name: 'Like',
|
||||
icon: 'sparkle-heart',
|
||||
position: 1,
|
||||
},
|
||||
{
|
||||
slug: 'unicorn',
|
||||
name: 'Unicorn',
|
||||
icon: 'multi-unicorn',
|
||||
position: 2,
|
||||
},
|
||||
],
|
||||
top_comments: [],
|
||||
},
|
||||
{
|
||||
title: 'The Torment of Others Quia recusandae',
|
||||
path: '/douglas_esmeralda/the-torment-of-others-quia-recusandae-4jmh',
|
||||
id: 126,
|
||||
user_id: 9,
|
||||
comments_count: 0,
|
||||
public_reactions_count: 2,
|
||||
organization_id: null,
|
||||
reading_time: 1,
|
||||
video_thumbnail_url: null,
|
||||
video: null,
|
||||
video_duration_in_minutes: '00:00',
|
||||
experience_level_rating: 5,
|
||||
experience_level_rating_distribution: 5,
|
||||
user: {
|
||||
name: 'Esmeralda "The Esmeralda" Douglas \\:/',
|
||||
username: 'douglas_esmeralda',
|
||||
slug: 'douglas_esmeralda',
|
||||
profile_image_90:
|
||||
'/uploads/user/profile_image/9/a80aa112-0bbc-4c25-9535-3a21d0cd56a0.png',
|
||||
profile_image_url:
|
||||
'/uploads/user/profile_image/9/a80aa112-0bbc-4c25-9535-3a21d0cd56a0.png',
|
||||
},
|
||||
pinned: false,
|
||||
main_image:
|
||||
'https://pigment.github.io/fake-logos/logos/medium/color/13.png',
|
||||
tag_list: [],
|
||||
readable_publish_date: 'May 15',
|
||||
flare_tag: null,
|
||||
class_name: 'Article',
|
||||
cloudinary_video_url: null,
|
||||
published_at_int: 1684156195,
|
||||
published_timestamp: '2023-05-15T13:09:55Z',
|
||||
main_image_background_hex_color: '#dddddd',
|
||||
public_reaction_categories: [
|
||||
{
|
||||
slug: 'unicorn',
|
||||
name: 'Unicorn',
|
||||
icon: 'multi-unicorn',
|
||||
position: 2,
|
||||
},
|
||||
],
|
||||
top_comments: [],
|
||||
},
|
||||
{
|
||||
title: 'Ah, Wilderness! Ad consectetur',
|
||||
path: '/tyler_blanda/ah-wilderness-ad-consectetur-2cj9',
|
||||
id: 146,
|
||||
user_id: 6,
|
||||
comments_count: 0,
|
||||
public_reactions_count: 2,
|
||||
organization_id: null,
|
||||
reading_time: 1,
|
||||
video_thumbnail_url: null,
|
||||
video: null,
|
||||
video_duration_in_minutes: '00:00',
|
||||
experience_level_rating: 5,
|
||||
experience_level_rating_distribution: 5,
|
||||
user: {
|
||||
name: 'Tyler "The Tyler" Blanda \\:/',
|
||||
username: 'tyler_blanda',
|
||||
slug: 'tyler_blanda',
|
||||
profile_image_90:
|
||||
'/uploads/user/profile_image/6/3cc1898b-cd2a-4ec2-a379-e0c9102a2262.png',
|
||||
profile_image_url:
|
||||
'/uploads/user/profile_image/6/3cc1898b-cd2a-4ec2-a379-e0c9102a2262.png',
|
||||
},
|
||||
pinned: false,
|
||||
main_image:
|
||||
'https://pigment.github.io/fake-logos/logos/medium/color/13.png',
|
||||
tag_list: [],
|
||||
readable_publish_date: 'May 15',
|
||||
flare_tag: null,
|
||||
class_name: 'Article',
|
||||
cloudinary_video_url: null,
|
||||
published_at_int: 1684156197,
|
||||
published_timestamp: '2023-05-15T13:09:57Z',
|
||||
main_image_background_hex_color: '#dddddd',
|
||||
public_reaction_categories: [
|
||||
{
|
||||
slug: 'unicorn',
|
||||
name: 'Unicorn',
|
||||
icon: 'multi-unicorn',
|
||||
position: 2,
|
||||
},
|
||||
],
|
||||
top_comments: [],
|
||||
},
|
||||
{
|
||||
title: 'Nectar in a Sieve Voluptas iusto',
|
||||
path: '/o_hara_aaron/nectar-in-a-sieve-voluptas-iusto-2937',
|
||||
id: 109,
|
||||
user_id: 5,
|
||||
comments_count: 0,
|
||||
public_reactions_count: 3,
|
||||
organization_id: null,
|
||||
reading_time: 1,
|
||||
video_thumbnail_url: null,
|
||||
video: null,
|
||||
video_duration_in_minutes: '00:00',
|
||||
experience_level_rating: 5,
|
||||
experience_level_rating_distribution: 5,
|
||||
user: {
|
||||
name: 'Aaron "The Aaron" O\'Hara \\:/',
|
||||
username: 'o_hara_aaron',
|
||||
slug: 'o_hara_aaron',
|
||||
profile_image_90:
|
||||
'/uploads/user/profile_image/5/e7705afd-19be-49a9-9a77-c20c061d3248.png',
|
||||
profile_image_url:
|
||||
'/uploads/user/profile_image/5/e7705afd-19be-49a9-9a77-c20c061d3248.png',
|
||||
},
|
||||
pinned: false,
|
||||
main_image:
|
||||
'https://pigment.github.io/fake-logos/logos/medium/color/10.png',
|
||||
tag_list: [],
|
||||
readable_publish_date: 'May 15',
|
||||
flare_tag: null,
|
||||
class_name: 'Article',
|
||||
cloudinary_video_url: null,
|
||||
published_at_int: 1684156194,
|
||||
published_timestamp: '2023-05-15T13:09:54Z',
|
||||
main_image_background_hex_color: '#dddddd',
|
||||
public_reaction_categories: [
|
||||
{
|
||||
slug: 'like',
|
||||
name: 'Like',
|
||||
icon: 'sparkle-heart',
|
||||
position: 1,
|
||||
},
|
||||
{
|
||||
slug: 'unicorn',
|
||||
name: 'Unicorn',
|
||||
icon: 'multi-unicorn',
|
||||
position: 2,
|
||||
},
|
||||
],
|
||||
top_comments: [],
|
||||
},
|
||||
{
|
||||
title: 'The Golden Bowl Unde velit',
|
||||
path: '/o_hara_aaron/the-golden-bowl-unde-velit-2d8e',
|
||||
id: 106,
|
||||
user_id: 5,
|
||||
comments_count: 0,
|
||||
public_reactions_count: 2,
|
||||
organization_id: null,
|
||||
reading_time: 1,
|
||||
video_thumbnail_url: null,
|
||||
video: null,
|
||||
video_duration_in_minutes: '00:00',
|
||||
experience_level_rating: 5,
|
||||
experience_level_rating_distribution: 5,
|
||||
user: {
|
||||
name: 'Aaron "The Aaron" O\'Hara \\:/',
|
||||
username: 'o_hara_aaron',
|
||||
slug: 'o_hara_aaron',
|
||||
profile_image_90:
|
||||
'/uploads/user/profile_image/5/e7705afd-19be-49a9-9a77-c20c061d3248.png',
|
||||
profile_image_url:
|
||||
'/uploads/user/profile_image/5/e7705afd-19be-49a9-9a77-c20c061d3248.png',
|
||||
},
|
||||
pinned: false,
|
||||
main_image:
|
||||
'https://pigment.github.io/fake-logos/logos/medium/color/10.png',
|
||||
tag_list: [],
|
||||
readable_publish_date: 'May 15',
|
||||
flare_tag: null,
|
||||
class_name: 'Article',
|
||||
cloudinary_video_url: null,
|
||||
published_at_int: 1684156193,
|
||||
published_timestamp: '2023-05-15T13:09:53Z',
|
||||
main_image_background_hex_color: '#dddddd',
|
||||
public_reaction_categories: [
|
||||
{
|
||||
slug: 'like',
|
||||
name: 'Like',
|
||||
icon: 'sparkle-heart',
|
||||
position: 1,
|
||||
},
|
||||
{
|
||||
slug: 'unicorn',
|
||||
name: 'Unicorn',
|
||||
icon: 'multi-unicorn',
|
||||
position: 2,
|
||||
},
|
||||
],
|
||||
top_comments: [],
|
||||
},
|
||||
];
|
||||
|
||||
export const feedPostsWherePinnedAndImagePostsSame = [
|
||||
{
|
||||
title: 'That Good Night Et molestias',
|
||||
path: '/douglas_esmeralda/that-good-night-et-molestias-5fak',
|
||||
id: 85,
|
||||
user_id: 9,
|
||||
comments_count: 0,
|
||||
public_reactions_count: 3,
|
||||
organization_id: null,
|
||||
reading_time: 1,
|
||||
video_thumbnail_url: null,
|
||||
video: null,
|
||||
video_duration_in_minutes: '00:00',
|
||||
experience_level_rating: 5,
|
||||
experience_level_rating_distribution: 5,
|
||||
user: {
|
||||
name: 'Esmeralda "The Esmeralda" Douglas \\:/',
|
||||
username: 'douglas_esmeralda',
|
||||
slug: 'douglas_esmeralda',
|
||||
profile_image_90:
|
||||
'/uploads/user/profile_image/9/a80aa112-0bbc-4c25-9535-3a21d0cd56a0.png',
|
||||
profile_image_url:
|
||||
'/uploads/user/profile_image/9/a80aa112-0bbc-4c25-9535-3a21d0cd56a0.png',
|
||||
},
|
||||
pinned: true,
|
||||
main_image: 'https://pigment.github.io/fake-logos/logos/medium/color/1.png',
|
||||
tag_list: ['performance', 'be', 'javascript'],
|
||||
readable_publish_date: 'May 15',
|
||||
flare_tag: null,
|
||||
class_name: 'Article',
|
||||
cloudinary_video_url: null,
|
||||
published_at_int: 1684156003,
|
||||
published_timestamp: '2023-05-15T13:06:43Z',
|
||||
main_image_background_hex_color: '#dddddd',
|
||||
public_reaction_categories: [
|
||||
{
|
||||
slug: 'like',
|
||||
name: 'Like',
|
||||
icon: 'sparkle-heart',
|
||||
position: 1,
|
||||
},
|
||||
{
|
||||
slug: 'unicorn',
|
||||
name: 'Unicorn',
|
||||
icon: 'multi-unicorn',
|
||||
position: 2,
|
||||
},
|
||||
],
|
||||
top_comments: [],
|
||||
},
|
||||
{
|
||||
title: 'After Many a Summer Dies the Swan Qui pariatur',
|
||||
path: '/tyler_blanda/after-many-a-summer-dies-the-swan-qui-pariatur-3o0p',
|
||||
id: 142,
|
||||
user_id: 6,
|
||||
comments_count: 0,
|
||||
public_reactions_count: 3,
|
||||
organization_id: null,
|
||||
reading_time: 1,
|
||||
video_thumbnail_url: null,
|
||||
video: null,
|
||||
video_duration_in_minutes: '00:00',
|
||||
experience_level_rating: 5,
|
||||
experience_level_rating_distribution: 5,
|
||||
user: {
|
||||
name: 'Tyler "The Tyler" Blanda \\:/',
|
||||
username: 'tyler_blanda',
|
||||
slug: 'tyler_blanda',
|
||||
profile_image_90:
|
||||
'/uploads/user/profile_image/6/3cc1898b-cd2a-4ec2-a379-e0c9102a2262.png',
|
||||
profile_image_url:
|
||||
'/uploads/user/profile_image/6/3cc1898b-cd2a-4ec2-a379-e0c9102a2262.png',
|
||||
},
|
||||
pinned: false,
|
||||
main_image:
|
||||
'https://pigment.github.io/fake-logos/logos/medium/color/12.png',
|
||||
tag_list: [],
|
||||
readable_publish_date: 'May 15',
|
||||
flare_tag: null,
|
||||
class_name: 'Article',
|
||||
cloudinary_video_url: null,
|
||||
published_at_int: 1684156197,
|
||||
published_timestamp: '2023-05-15T13:09:57Z',
|
||||
main_image_background_hex_color: '#dddddd',
|
||||
public_reaction_categories: [
|
||||
{
|
||||
slug: 'like',
|
||||
name: 'Like',
|
||||
icon: 'sparkle-heart',
|
||||
position: 1,
|
||||
},
|
||||
{
|
||||
slug: 'unicorn',
|
||||
name: 'Unicorn',
|
||||
icon: 'multi-unicorn',
|
||||
position: 2,
|
||||
},
|
||||
],
|
||||
top_comments: [],
|
||||
},
|
||||
{
|
||||
title: 'Number the Stars Et ab',
|
||||
path: '/schroeder_irvin/number-the-stars-et-ab-18p4',
|
||||
id: 143,
|
||||
user_id: 2,
|
||||
comments_count: 0,
|
||||
public_reactions_count: 4,
|
||||
organization_id: null,
|
||||
reading_time: 1,
|
||||
video_thumbnail_url: null,
|
||||
video: null,
|
||||
video_duration_in_minutes: '00:00',
|
||||
experience_level_rating: 5,
|
||||
experience_level_rating_distribution: 5,
|
||||
user: {
|
||||
name: 'Irvin "The Irvin" Schroeder \\:/',
|
||||
username: 'schroeder_irvin',
|
||||
slug: 'schroeder_irvin',
|
||||
profile_image_90:
|
||||
'/uploads/user/profile_image/2/8eb14853-857f-4502-a9fd-507b27c6c300.png',
|
||||
profile_image_url:
|
||||
'/uploads/user/profile_image/2/8eb14853-857f-4502-a9fd-507b27c6c300.png',
|
||||
},
|
||||
pinned: false,
|
||||
main_image: 'https://pigment.github.io/fake-logos/logos/medium/color/7.png',
|
||||
tag_list: [],
|
||||
readable_publish_date: 'May 15',
|
||||
flare_tag: null,
|
||||
class_name: 'Article',
|
||||
cloudinary_video_url: null,
|
||||
published_at_int: 1684156197,
|
||||
published_timestamp: '2023-05-15T13:09:57Z',
|
||||
main_image_background_hex_color: '#dddddd',
|
||||
public_reaction_categories: [
|
||||
{
|
||||
slug: 'like',
|
||||
name: 'Like',
|
||||
icon: 'sparkle-heart',
|
||||
position: 1,
|
||||
},
|
||||
{
|
||||
slug: 'unicorn',
|
||||
name: 'Unicorn',
|
||||
icon: 'multi-unicorn',
|
||||
position: 2,
|
||||
},
|
||||
],
|
||||
top_comments: [],
|
||||
},
|
||||
];
|
||||
|
||||
export const podcastEpisodes = [
|
||||
{
|
||||
slug: 's22e8-from-opera-to-code-anna-mcdougall',
|
||||
title: 'S22:E8 - From Opera to Code (Anna McDougall)',
|
||||
podcast_id: 1,
|
||||
image: { url: null },
|
||||
id: null,
|
||||
tag_list: [],
|
||||
podcast: {
|
||||
id: 1,
|
||||
title: 'CodeNewbie',
|
||||
slug: 'codenewbie',
|
||||
image_90:
|
||||
'/uploads/podcast/image/1/3f7dca78-b9fb-4b7f-b985-49b34417d37b.jpeg',
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: 's22e2-building-the-bridge-across-the-tech-gap-michelle-glauser',
|
||||
title:
|
||||
'S22:E2 - Building the bridge across the tech gap (Michelle Glauser)',
|
||||
podcast_id: 1,
|
||||
image: { url: null },
|
||||
id: null,
|
||||
tag_list: [],
|
||||
podcast: {
|
||||
id: 1,
|
||||
title: 'CodeNewbie',
|
||||
slug: 'codenewbie',
|
||||
image_90:
|
||||
'/uploads/podcast/image/1/3f7dca78-b9fb-4b7f-b985-49b34417d37b.jpeg',
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: 's22e7-starting-out-in-open-source-brian-douglas',
|
||||
title: 'S22:E7 - Starting out in Open Source (Brian Douglas)',
|
||||
podcast_id: 1,
|
||||
image: { url: null },
|
||||
id: null,
|
||||
tag_list: [],
|
||||
podcast: {
|
||||
id: 1,
|
||||
title: 'CodeNewbie',
|
||||
slug: 'codenewbie',
|
||||
image_90:
|
||||
'/uploads/podcast/image/1/3f7dca78-b9fb-4b7f-b985-49b34417d37b.jpeg',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const firstBillboard = 'billboard one';
|
||||
export const secondBillboard = 'billboard two';
|
||||
export const thirdBillboard = 'billboard three';
|
||||
117
app/javascript/packs/billboardAfterRenderActions.js
Normal file
117
app/javascript/packs/billboardAfterRenderActions.js
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
/* global userData */
|
||||
// This is currently a duplicate of app/assets/javascript/initializers/initializeDisplayAdVisibility.
|
||||
export function initializeDisplayAdVisibility() {
|
||||
const displayAds = document.querySelectorAll('[data-display-unit]');
|
||||
|
||||
if (displayAds && displayAds.length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const user = userData();
|
||||
|
||||
displayAds.forEach((ad) => {
|
||||
if (user && !user.display_sponsors && ad.dataset['typeOf'] == 'external') {
|
||||
ad.classList.add('hidden');
|
||||
} else {
|
||||
ad.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function observeDisplayAds() {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
const elem = entry.target;
|
||||
if (entry.intersectionRatio >= 0.25) {
|
||||
setTimeout(() => {
|
||||
trackAdImpression(elem);
|
||||
}, 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
root: null, // defaults to browser viewport
|
||||
rootMargin: '0px',
|
||||
threshold: 0.25,
|
||||
},
|
||||
);
|
||||
|
||||
document.querySelectorAll('[data-display-unit]').forEach((ad) => {
|
||||
observer.observe(ad);
|
||||
ad.removeEventListener('click', trackAdClick, false);
|
||||
ad.addEventListener('click', () => trackAdClick(ad));
|
||||
});
|
||||
}
|
||||
|
||||
function trackAdImpression(adBox) {
|
||||
const isBot =
|
||||
/bot|google|baidu|bing|msn|duckduckbot|teoma|slurp|yandex/i.test(
|
||||
navigator.userAgent,
|
||||
); // is crawler
|
||||
const adSeen = adBox.dataset.impressionRecorded;
|
||||
if (isBot || adSeen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenMeta = document.querySelector("meta[name='csrf-token']");
|
||||
const csrfToken = tokenMeta && tokenMeta.getAttribute('content');
|
||||
|
||||
const dataBody = {
|
||||
display_ad_event: {
|
||||
display_ad_id: adBox.dataset.id,
|
||||
context_type: adBox.dataset.contextType,
|
||||
category: adBox.dataset.categoryImpression,
|
||||
},
|
||||
};
|
||||
|
||||
window
|
||||
.fetch('/display_ad_events', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-Token': csrfToken,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(dataBody),
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
.catch((error) => console.error(error));
|
||||
|
||||
adBox.dataset.impressionRecorded = true;
|
||||
}
|
||||
|
||||
function trackAdClick(adBox) {
|
||||
const isBot =
|
||||
/bot|google|baidu|bing|msn|duckduckbot|teoma|slurp|yandex/i.test(
|
||||
navigator.userAgent,
|
||||
); // is crawler
|
||||
const adClicked = adBox.dataset.clickRecorded;
|
||||
if (isBot || adClicked) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenMeta = document.querySelector("meta[name='csrf-token']");
|
||||
const csrfToken = tokenMeta && tokenMeta.getAttribute('content');
|
||||
|
||||
const dataBody = {
|
||||
display_ad_event: {
|
||||
display_ad_id: adBox.dataset.id,
|
||||
context_type: adBox.dataset.contextType,
|
||||
category: adBox.dataset.categoryClick,
|
||||
},
|
||||
};
|
||||
|
||||
window.fetch('/display_ad_events', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-Token': csrfToken,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(dataBody),
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
|
||||
adBox.dataset.clickRecorded = true;
|
||||
}
|
||||
|
|
@ -1,6 +1,10 @@
|
|||
import { h, render } from 'preact';
|
||||
import ahoy from 'ahoy.js';
|
||||
import { TagsFollowed } from '../leftSidebar/TagsFollowed';
|
||||
import {
|
||||
observeDisplayAds,
|
||||
initializeDisplayAdVisibility,
|
||||
} from '../packs/billboardAfterRenderActions';
|
||||
import { setupDisplayAdDropdown } from '@utilities/displayAdDropdown';
|
||||
import { trackCreateAccountClicks } from '@utilities/ahoy/trackEvents';
|
||||
|
||||
|
|
@ -93,8 +97,12 @@ if (!document.getElementById('featured-story-marker')) {
|
|||
return;
|
||||
}
|
||||
import('./homePageFeed').then(({ renderFeed }) => {
|
||||
// We have user data, render followed tags.
|
||||
renderFeed(feedTimeFrame);
|
||||
const callback = () => {
|
||||
initializeDisplayAdVisibility();
|
||||
observeDisplayAds();
|
||||
};
|
||||
|
||||
renderFeed(feedTimeFrame, callback);
|
||||
|
||||
InstantClick.on('change', () => {
|
||||
const { userStatus: currentUserStatus } = document.body.dataset;
|
||||
|
|
@ -110,7 +118,12 @@ if (!document.getElementById('featured-story-marker')) {
|
|||
return;
|
||||
}
|
||||
|
||||
renderFeed(changedFeedTimeFrame);
|
||||
const callback = () => {
|
||||
initializeDisplayAdVisibility();
|
||||
observeDisplayAds();
|
||||
};
|
||||
|
||||
renderFeed(changedFeedTimeFrame, callback);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,92 @@ function sendFeaturedArticleAnalyticsGA4(articleId) {
|
|||
})();
|
||||
}
|
||||
|
||||
function feedConstruct(
|
||||
pinnedItem,
|
||||
imageItem,
|
||||
feedItems,
|
||||
bookmarkedFeedItems,
|
||||
bookmarkClick,
|
||||
currentUserId,
|
||||
timeFrame,
|
||||
) {
|
||||
const commonProps = {
|
||||
bookmarkClick,
|
||||
};
|
||||
|
||||
const feedStyle = JSON.parse(document.body.dataset.user).feed_style;
|
||||
|
||||
if (imageItem) {
|
||||
sendFeaturedArticleGoogleAnalytics(imageItem.id);
|
||||
sendFeaturedArticleAnalyticsGA4(imageItem.id);
|
||||
}
|
||||
|
||||
return feedItems.map((item) => {
|
||||
// billboard is an html string
|
||||
if (typeof item === 'string') {
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: item,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (Array.isArray(item) && item[0].podcast) {
|
||||
return <PodcastEpisodes key={item.id} episodes={item} />;
|
||||
}
|
||||
|
||||
if (typeof item === 'object') {
|
||||
// For "saveable" props, "!=" is used instead of "!==" to compare user_id
|
||||
// and currentUserId because currentUserId is a String while user_id is an Integer
|
||||
|
||||
if (item.id === pinnedItem?.id && timeFrame === '') {
|
||||
return (
|
||||
<Article
|
||||
{...commonProps}
|
||||
key={item.id}
|
||||
article={pinnedItem}
|
||||
pinned={true}
|
||||
feedStyle={feedStyle}
|
||||
isBookmarked={bookmarkedFeedItems.has(pinnedItem?.id)}
|
||||
saveable={pinnedItem.user_id != currentUserId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.id === imageItem?.id) {
|
||||
return (
|
||||
<Article
|
||||
{...commonProps}
|
||||
key={item.id}
|
||||
article={imageItem}
|
||||
isFeatured
|
||||
feedStyle={feedStyle}
|
||||
isBookmarked={bookmarkedFeedItems.has(imageItem.id)}
|
||||
saveable={imageItem.user_id != currentUserId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.class_name === 'Article') {
|
||||
return (
|
||||
<Article
|
||||
{...commonProps}
|
||||
key={item.id}
|
||||
article={item}
|
||||
feedStyle={feedStyle}
|
||||
isBookmarked={bookmarkedFeedItems.has(item.id)}
|
||||
saveable={item.user_id != currentUserId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const FeedLoading = () => (
|
||||
<div data-testid="feed-loading">
|
||||
<LoadingArticle version="featured" />
|
||||
|
|
@ -74,83 +160,44 @@ PodcastEpisodes.propTypes = {
|
|||
/**
|
||||
* Renders the main feed.
|
||||
*/
|
||||
export const renderFeed = async (timeFrame) => {
|
||||
export const renderFeed = async (timeFrame, afterRender) => {
|
||||
const feedContainer = document.getElementById('homepage-feed');
|
||||
|
||||
const { currentUser } = await getUserDataAndCsrfToken();
|
||||
const currentUserId = currentUser && currentUser.id;
|
||||
|
||||
const callback = ({
|
||||
pinnedItem,
|
||||
imageItem,
|
||||
feedItems,
|
||||
bookmarkedFeedItems,
|
||||
bookmarkClick,
|
||||
}) => {
|
||||
if (feedItems.length === 0) {
|
||||
// Fancy loading ✨
|
||||
return <FeedLoading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{feedConstruct(
|
||||
pinnedItem,
|
||||
imageItem,
|
||||
feedItems,
|
||||
bookmarkedFeedItems,
|
||||
bookmarkClick,
|
||||
currentUserId,
|
||||
timeFrame,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
render(
|
||||
<Feed
|
||||
timeFrame={timeFrame}
|
||||
renderFeed={({
|
||||
pinnedArticle,
|
||||
feedItems,
|
||||
podcastEpisodes,
|
||||
bookmarkedFeedItems,
|
||||
bookmarkClick,
|
||||
}) => {
|
||||
if (feedItems.length === 0) {
|
||||
// Fancy loading ✨
|
||||
return <FeedLoading />;
|
||||
}
|
||||
|
||||
const commonProps = {
|
||||
bookmarkClick,
|
||||
};
|
||||
|
||||
const feedStyle = JSON.parse(document.body.dataset.user).feed_style;
|
||||
|
||||
const [featuredStory, ...subStories] = feedItems;
|
||||
if (featuredStory) {
|
||||
sendFeaturedArticleGoogleAnalytics(featuredStory.id);
|
||||
sendFeaturedArticleAnalyticsGA4(featuredStory.id);
|
||||
}
|
||||
|
||||
// 1. Show the pinned article first
|
||||
// 2. Show the featured story next
|
||||
// 3. Podcast episodes out today
|
||||
// 4. Rest of the stories for the feed
|
||||
// For "saveable", "!=" is used instead of "!==" to compare user_id
|
||||
// and currentUserId because currentUserId is a String while user_id is an Integer
|
||||
return (
|
||||
<div>
|
||||
{timeFrame === '' && pinnedArticle && (
|
||||
<Article
|
||||
{...commonProps}
|
||||
article={pinnedArticle}
|
||||
pinned={true}
|
||||
feedStyle={feedStyle}
|
||||
isBookmarked={bookmarkedFeedItems.has(pinnedArticle.id)}
|
||||
saveable={pinnedArticle.user_id != currentUserId}
|
||||
/>
|
||||
)}
|
||||
{featuredStory && (
|
||||
<Article
|
||||
{...commonProps}
|
||||
article={featuredStory}
|
||||
isFeatured
|
||||
feedStyle={feedStyle}
|
||||
isBookmarked={bookmarkedFeedItems.has(featuredStory.id)}
|
||||
saveable={featuredStory.user_id != currentUserId}
|
||||
/>
|
||||
)}
|
||||
{podcastEpisodes.length > 0 && (
|
||||
<PodcastEpisodes episodes={podcastEpisodes} />
|
||||
)}
|
||||
{(subStories || []).map((story) => (
|
||||
<Article
|
||||
{...commonProps}
|
||||
key={story.id}
|
||||
article={story}
|
||||
feedStyle={feedStyle}
|
||||
isBookmarked={bookmarkedFeedItems.has(story.id)}
|
||||
saveable={story.user_id != currentUserId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
renderFeed={callback}
|
||||
afterRender={afterRender}
|
||||
/>,
|
||||
feedContainer,
|
||||
feedContainer.firstElementChild,
|
||||
|
|
|
|||
12
app/javascript/utilities/__tests__/insertInArrayIf.test.js
Normal file
12
app/javascript/utilities/__tests__/insertInArrayIf.test.js
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { insertInArrayIf } from '@utilities/insertInArrayIf';
|
||||
|
||||
describe('insertInArrayIf Utility', () => {
|
||||
it('should return insert into the array based on what the condition evaluates to', () => {
|
||||
const trueCondition = true;
|
||||
const falseCondition = false;
|
||||
const object = { a: 1, b: 1 };
|
||||
|
||||
expect(insertInArrayIf(trueCondition, object)).toEqual([object]);
|
||||
expect(insertInArrayIf(falseCondition, object)).toEqual([]);
|
||||
});
|
||||
});
|
||||
3
app/javascript/utilities/insertInArrayIf.jsx
Normal file
3
app/javascript/utilities/insertInArrayIf.jsx
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export function insertInArrayIf(condition, ...elements) {
|
||||
return condition ? elements : [];
|
||||
}
|
||||
|
|
@ -202,6 +202,7 @@ Rails.application.routes.draw do
|
|||
scope "/:username/:slug" do
|
||||
get "/display_ads/:placement_area", to: "display_ads#show", as: :article_display_ad
|
||||
end
|
||||
get "/display_ads/:placement_area", to: "display_ads#show"
|
||||
|
||||
# Settings
|
||||
post "users/join_org", to: "users#join_org"
|
||||
|
|
|
|||
|
|
@ -5505,9 +5505,9 @@ caniuse-api@^3.0.0:
|
|||
lodash.uniq "^4.5.0"
|
||||
|
||||
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000981, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001304, caniuse-lite@^1.0.30001400, caniuse-lite@^1.0.30001426:
|
||||
version "1.0.30001474"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001474.tgz#13b6fe301a831fe666cce8ca4ef89352334133d5"
|
||||
integrity sha512-iaIZ8gVrWfemh5DG3T9/YqarVZoYf0r188IjaGwx68j4Pf0SGY6CQkmJUIE+NZHkkecQGohzXmBGEwWDr9aM3Q==
|
||||
version "1.0.30001489"
|
||||
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001489.tgz"
|
||||
integrity sha512-x1mgZEXK8jHIfAxm+xgdpHpk50IN3z3q3zP261/WS+uvePxW8izXuCu6AHz0lkuYTlATDehiZ/tNyYBdSQsOUQ==
|
||||
|
||||
canvas@^2.11.0:
|
||||
version "2.11.2"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue