diff --git a/app/soapbox/actions/__tests__/carousels.test.ts b/app/soapbox/actions/__tests__/carousels.test.ts deleted file mode 100644 index 44e4ff0c0..000000000 --- a/app/soapbox/actions/__tests__/carousels.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { __stub } from 'soapbox/api'; -import { mockStore, rootState } from 'soapbox/jest/test-helpers'; - -import { fetchCarouselAvatars } from '../carousels'; - -describe('fetchCarouselAvatars()', () => { - let store: ReturnType; - - beforeEach(() => { - store = mockStore(rootState); - }); - - describe('with a successful API request', () => { - let avatars: Record[]; - - beforeEach(() => { - avatars = [ - { account_id: '1', acct: 'jl', account_avatar: 'https://example.com/some.jpg' }, - ]; - - __stub((mock) => { - mock.onGet('/api/v1/truth/carousels/avatars').reply(200, avatars); - }); - }); - - it('should fetch the users from the API', async() => { - const expectedActions = [ - { type: 'CAROUSEL_AVATAR_REQUEST' }, - { type: 'CAROUSEL_AVATAR_SUCCESS', payload: avatars }, - ]; - - await store.dispatch(fetchCarouselAvatars()); - const actions = store.getActions(); - - expect(actions).toEqual(expectedActions); - }); - }); - - describe('with an unsuccessful API request', () => { - beforeEach(() => { - __stub((mock) => { - mock.onGet('/api/v1/truth/carousels/avatars').networkError(); - }); - }); - - it('should dispatch failed action', async() => { - const expectedActions = [ - { type: 'CAROUSEL_AVATAR_REQUEST' }, - { type: 'CAROUSEL_AVATAR_FAIL' }, - ]; - - await store.dispatch(fetchCarouselAvatars()); - const actions = store.getActions(); - - expect(actions).toEqual(expectedActions); - }); - }); -}); diff --git a/app/soapbox/actions/carousels.ts b/app/soapbox/actions/carousels.ts deleted file mode 100644 index 7935536c4..000000000 --- a/app/soapbox/actions/carousels.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { AxiosResponse } from 'axios'; - -import { AppDispatch, RootState } from 'soapbox/store'; - -import api from '../api'; - -const CAROUSEL_AVATAR_REQUEST = 'CAROUSEL_AVATAR_REQUEST'; -const CAROUSEL_AVATAR_SUCCESS = 'CAROUSEL_AVATAR_SUCCESS'; -const CAROUSEL_AVATAR_FAIL = 'CAROUSEL_AVATAR_FAIL'; - -const fetchCarouselAvatars = () => (dispatch: AppDispatch, getState: () => RootState) => { - dispatch({ type: CAROUSEL_AVATAR_REQUEST }); - - return api(getState) - .get('/api/v1/truth/carousels/avatars') - .then((response: AxiosResponse) => dispatch({ type: CAROUSEL_AVATAR_SUCCESS, payload: response.data })) - .catch(() => dispatch({ type: CAROUSEL_AVATAR_FAIL })); -}; - -export { - CAROUSEL_AVATAR_REQUEST, - CAROUSEL_AVATAR_SUCCESS, - CAROUSEL_AVATAR_FAIL, - fetchCarouselAvatars, -}; diff --git a/app/soapbox/features/feed-filtering/__tests__/feed-carousel.test.tsx b/app/soapbox/features/feed-filtering/__tests__/feed-carousel.test.tsx index 350cf385f..9cc88a6c3 100644 --- a/app/soapbox/features/feed-filtering/__tests__/feed-carousel.test.tsx +++ b/app/soapbox/features/feed-filtering/__tests__/feed-carousel.test.tsx @@ -2,7 +2,8 @@ import userEvent from '@testing-library/user-event'; import { Map as ImmutableMap } from 'immutable'; import React from 'react'; -import { __stub } from '../../../api'; +import { __stub } from 'soapbox/api'; + import { render, screen, waitFor } from '../../../jest/test-helpers'; import FeedCarousel from '../feed-carousel'; @@ -55,55 +56,59 @@ describe('', () => { }; }); - it('should render the Carousel', () => { - store.carousels = { - avatars: [ - { account_id: '1', acct: 'a', account_avatar: 'https://example.com/some.jpg' }, - ], - }; + describe('with avatars', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet('/api/v1/truth/carousels/avatars') + .reply(200, [ + { account_id: '1', acct: 'a', account_avatar: 'https://example.com/some.jpg' }, + { account_id: '2', acct: 'b', account_avatar: 'https://example.com/some.jpg' }, + { account_id: '3', acct: 'c', account_avatar: 'https://example.com/some.jpg' }, + { account_id: '4', acct: 'd', account_avatar: 'https://example.com/some.jpg' }, + ]); + }); + }); - render(, undefined, store); + it('should render the Carousel', async() => { + render(, undefined, store); - expect(screen.queryAllByTestId('feed-carousel')).toHaveLength(1); + await waitFor(() => { + expect(screen.queryAllByTestId('feed-carousel')).toHaveLength(1); + }); + }); }); describe('with 0 avatars', () => { beforeEach(() => { - store.carousels = { - avatars: [], - }; + __stub((mock) => mock.onGet('/api/v1/truth/carousels/avatars').reply(200, [])); }); - it('renders the error message', () => { + it('renders nothing', async() => { render(, undefined, store); - expect(screen.queryAllByTestId('feed-carousel-error')).toHaveLength(0); + await waitFor(() => { + expect(screen.queryAllByTestId('feed-carousel')).toHaveLength(0); + }); }); }); describe('with a failed request to the API', () => { beforeEach(() => { - store.carousels = { - avatars: [], - error: true, - }; + __stub((mock) => mock.onGet('/api/v1/truth/carousels/avatars').networkError()); }); - it('renders the error message', () => { + it('renders the error message', async() => { render(, undefined, store); - expect(screen.getByTestId('feed-carousel-error')).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByTestId('feed-carousel-error')).toBeInTheDocument(); + }); }); }); describe('with multiple pages of avatars', () => { beforeEach(() => { - store.carousels = { - error: false, - avatars: [], - }; - - __stub(mock => { + __stub((mock) => { mock.onGet('/api/v1/truth/carousels/avatars') .reply(200, [ { account_id: '1', acct: 'a', account_avatar: 'https://example.com/some.jpg' }, diff --git a/app/soapbox/features/feed-filtering/feed-carousel.tsx b/app/soapbox/features/feed-filtering/feed-carousel.tsx index da1c3faee..7a2b64b2c 100644 --- a/app/soapbox/features/feed-filtering/feed-carousel.tsx +++ b/app/soapbox/features/feed-filtering/feed-carousel.tsx @@ -2,9 +2,9 @@ import classNames from 'classnames'; import React, { useEffect, useState } from 'react'; import { FormattedMessage } from 'react-intl'; -import { fetchCarouselAvatars } from 'soapbox/actions/carousels'; import { replaceHomeTimeline } from 'soapbox/actions/timelines'; import { useAppDispatch, useAppSelector, useDimensions, useFeatures } from 'soapbox/hooks'; +import useCarouselAvatars from 'soapbox/queries/carousels'; import { Card, HStack, Icon, Stack, Text } from '../../components/ui'; import PlaceholderAvatar from '../placeholder/components/placeholder_avatar'; @@ -15,10 +15,10 @@ const CarouselItem = ({ avatar }: { avatar: any }) => { const selectedAccountId = useAppSelector(state => state.timelines.get('home')?.feedAccountId); const isSelected = avatar.account_id === selectedAccountId; - const [isLoading, setLoading] = useState(false); + const [isFetching, setLoading] = useState(false); const handleClick = () => { - if (isLoading) { + if (isFetching) { return; } @@ -32,7 +32,7 @@ const CarouselItem = ({ avatar }: { avatar: any }) => { }; return ( -
+
{isSelected && ( @@ -59,17 +59,15 @@ const CarouselItem = ({ avatar }: { avatar: any }) => { }; const FeedCarousel = () => { - const dispatch = useAppDispatch(); const features = useFeatures(); + const { data: avatars, isFetching, isError } = useCarouselAvatars(); + const [cardRef, setCardRef, { width }] = useDimensions(); const [pageSize, setPageSize] = useState(0); const [currentPage, setCurrentPage] = useState(1); - const avatars = useAppSelector((state) => state.carousels.avatars); - const isLoading = useAppSelector((state) => state.carousels.isLoading); - const hasError = useAppSelector((state) => state.carousels.error); const numberOfPages = Math.ceil(avatars.length / pageSize); const widthPerAvatar = (cardRef?.scrollWidth || 0) / avatars.length; @@ -85,17 +83,11 @@ const FeedCarousel = () => { } }, [width, widthPerAvatar]); - useEffect(() => { - if (features.feedUserFiltering) { - dispatch(fetchCarouselAvatars()); - } - }, []); - if (!features.feedUserFiltering) { return null; } - if (hasError) { + if (isError) { return ( @@ -133,7 +125,7 @@ const FeedCarousel = () => { style={{ transform: `translateX(-${(currentPage - 1) * 100}%)` }} ref={setCardRef} > - {isLoading ? ( + {isFetching ? ( new Array(pageSize).fill(0).map((_, idx) => (
diff --git a/app/soapbox/hooks/index.ts b/app/soapbox/hooks/index.ts index aad62b1f1..4d7417771 100644 --- a/app/soapbox/hooks/index.ts +++ b/app/soapbox/hooks/index.ts @@ -1,4 +1,5 @@ export { useAccount } from './useAccount'; +export { useApi } from './useApi'; export { useAppDispatch } from './useAppDispatch'; export { useAppSelector } from './useAppSelector'; export { useDimensions } from './useDimensions'; diff --git a/app/soapbox/hooks/useApi.ts b/app/soapbox/hooks/useApi.ts new file mode 100644 index 000000000..1d98a6166 --- /dev/null +++ b/app/soapbox/hooks/useApi.ts @@ -0,0 +1,12 @@ +import api from 'soapbox/api'; + +import { useAppDispatch } from './useAppDispatch'; + +/** Use stateful Axios client with auth from Redux. */ +export const useApi = () => { + const dispatch = useAppDispatch(); + + return dispatch((_dispatch, getState) => { + return api(getState); + }); +}; diff --git a/app/soapbox/jest/test-helpers.tsx b/app/soapbox/jest/test-helpers.tsx index b7223caca..0894d4d40 100644 --- a/app/soapbox/jest/test-helpers.tsx +++ b/app/soapbox/jest/test-helpers.tsx @@ -1,6 +1,7 @@ import { configureMockStore } from '@jedmao/redux-mock-store'; -import { QueryClientProvider } from '@tanstack/react-query'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { render, RenderOptions } from '@testing-library/react'; +import { renderHook, RenderHookOptions } from '@testing-library/react-hooks'; import { merge } from 'immutable'; import React, { FC, ReactElement } from 'react'; import { IntlProvider } from 'react-intl'; @@ -10,8 +11,6 @@ import { Action, applyMiddleware, createStore } from 'redux'; import thunk from 'redux-thunk'; import '@testing-library/jest-dom'; -import { queryClient } from 'soapbox/queries/client'; - import NotificationsContainer from '../features/ui/containers/notifications_container'; import { default as rootReducer } from '../reducers'; @@ -28,8 +27,22 @@ const applyActions = (state: any, actions: any, reducer: any) => { return actions.reduce((state: any, action: any) => reducer(state, action), state); }; -const createTestStore = (initialState: any) => createStore(rootReducer, initialState, applyMiddleware(thunk)); +/** React Query client for tests. */ +const queryClient = new QueryClient({ + logger: { + // eslint-disable-next-line no-console + log: console.log, + warn: console.warn, + error: () => { }, + }, + defaultOptions: { + queries: { + retry: false, + }, + }, +}); +const createTestStore = (initialState: any) => createStore(rootReducer, initialState, applyMiddleware(thunk)); const TestApp: FC = ({ children, storeProps, routerProps = {} }) => { let store: ReturnType; let appState = rootState; @@ -71,6 +84,18 @@ const customRender = ( ...options, }); +/** Like renderHook, but with access to the Redux store. */ +const customRenderHook = ( + callback: (props?: any) => any, + options?: Omit, 'wrapper'>, + store?: any, +) => { + return renderHook(callback, { + wrapper: ({ children }) => , + ...options, + }); +}; + const mockWindowProperty = (property: any, value: any) => { const { [property]: originalProperty } = window; delete window[property]; @@ -91,6 +116,7 @@ const mockWindowProperty = (property: any, value: any) => { export * from '@testing-library/react'; export { customRender as render, + customRenderHook as renderHook, mockStore, applyActions, rootState, diff --git a/app/soapbox/queries/__tests__/carousels.test.ts b/app/soapbox/queries/__tests__/carousels.test.ts new file mode 100644 index 000000000..9ee5fa4c2 --- /dev/null +++ b/app/soapbox/queries/__tests__/carousels.test.ts @@ -0,0 +1,44 @@ +import { __stub } from 'soapbox/api'; +import { renderHook, waitFor } from 'soapbox/jest/test-helpers'; + +import useCarouselAvatars from '../carousels'; + +describe('useCarouselAvatars', () => { + describe('with a successful query', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet('/api/v1/truth/carousels/avatars') + .reply(200, [ + { account_id: '1', acct: 'a', account_avatar: 'https://example.com/some.jpg' }, + { account_id: '2', acct: 'b', account_avatar: 'https://example.com/some.jpg' }, + { account_id: '3', acct: 'c', account_avatar: 'https://example.com/some.jpg' }, + { account_id: '4', acct: 'd', account_avatar: 'https://example.com/some.jpg' }, + ]); + }); + }); + + it('is successful', async() => { + const { result } = renderHook(() => useCarouselAvatars()); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + expect(result.current.data?.length).toBe(4); + }); + }); + + describe('with an unsuccessful query', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet('/api/v1/truth/carousels/avatars').networkError(); + }); + }); + + it('is successful', async() => { + const { result } = renderHook(() => useCarouselAvatars()); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + expect(result.current.error).toBeDefined(); + }); + }); +}); diff --git a/app/soapbox/queries/carousels.ts b/app/soapbox/queries/carousels.ts new file mode 100644 index 000000000..7d295183e --- /dev/null +++ b/app/soapbox/queries/carousels.ts @@ -0,0 +1,29 @@ +import { useQuery } from '@tanstack/react-query'; + +import { useApi } from 'soapbox/hooks'; + +type Avatar = { + account_id: string + account_avatar: string + username: string +} + +export default function useCarouselAvatars() { + const api = useApi(); + + const getCarouselAvatars = async() => { + const { data } = await api.get('/api/v1/truth/carousels/avatars'); + return data; + }; + + const result = useQuery(['carouselAvatars'], getCarouselAvatars, { + placeholderData: [], + }); + + const avatars = result.data; + + return { + ...result, + data: avatars || [], + }; +} diff --git a/app/soapbox/reducers/__tests__/carousels.test.ts b/app/soapbox/reducers/__tests__/carousels.test.ts deleted file mode 100644 index 2745b078d..000000000 --- a/app/soapbox/reducers/__tests__/carousels.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { AnyAction } from 'redux'; - -import { - CAROUSEL_AVATAR_REQUEST, - CAROUSEL_AVATAR_SUCCESS, - CAROUSEL_AVATAR_FAIL, -} from 'soapbox/actions/carousels'; - -import reducer from '../carousels'; - -describe('carousels reducer', () => { - it('should return the initial state', () => { - expect(reducer(undefined, {} as AnyAction)).toEqual({ - avatars: [], - error: false, - isLoading: false, - }); - }); - - describe('CAROUSEL_AVATAR_REQUEST', () => { - it('sets "isLoading" to "true"', () => { - const initialState = { isLoading: false, avatars: [], error: false }; - const action = { type: CAROUSEL_AVATAR_REQUEST }; - expect(reducer(initialState, action).isLoading).toEqual(true); - }); - }); - - describe('CAROUSEL_AVATAR_SUCCESS', () => { - it('sets the next state', () => { - const initialState = { isLoading: true, avatars: [], error: false }; - const action = { type: CAROUSEL_AVATAR_SUCCESS, payload: [45] }; - const result = reducer(initialState, action); - - expect(result.isLoading).toEqual(false); - expect(result.avatars).toEqual([45]); - expect(result.error).toEqual(false); - }); - }); - - describe('CAROUSEL_AVATAR_FAIL', () => { - it('sets "isLoading" to "true"', () => { - const initialState = { isLoading: true, avatars: [], error: false }; - const action = { type: CAROUSEL_AVATAR_FAIL }; - const result = reducer(initialState, action); - - expect(result.isLoading).toEqual(false); - expect(result.error).toEqual(true); - }); - }); -}); diff --git a/app/soapbox/reducers/carousels.ts b/app/soapbox/reducers/carousels.ts deleted file mode 100644 index 091c47238..000000000 --- a/app/soapbox/reducers/carousels.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { AnyAction } from 'redux'; - -import { - CAROUSEL_AVATAR_REQUEST, - CAROUSEL_AVATAR_SUCCESS, - CAROUSEL_AVATAR_FAIL, -} from '../actions/carousels'; - -type Avatar = { - account_id: string - account_avatar: string - username: string -} - -type CarouselsState = { - avatars: Avatar[] - isLoading: boolean - error: boolean -} - -const initialState: CarouselsState = { - avatars: [], - isLoading: false, - error: false, -}; - -export default function rules(state: CarouselsState = initialState, action: AnyAction): CarouselsState { - switch (action.type) { - case CAROUSEL_AVATAR_REQUEST: - return { ...state, isLoading: true }; - case CAROUSEL_AVATAR_SUCCESS: - return { ...state, isLoading: false, avatars: action.payload }; - case CAROUSEL_AVATAR_FAIL: - return { ...state, isLoading: false, error: true }; - default: - return state; - } -} diff --git a/app/soapbox/reducers/index.ts b/app/soapbox/reducers/index.ts index fda62e79e..87750381b 100644 --- a/app/soapbox/reducers/index.ts +++ b/app/soapbox/reducers/index.ts @@ -15,7 +15,6 @@ import aliases from './aliases'; import announcements from './announcements'; import auth from './auth'; import backups from './backups'; -import carousels from './carousels'; import chat_message_lists from './chat_message_lists'; import chat_messages from './chat_messages'; import chats from './chats'; @@ -124,7 +123,6 @@ const reducers = { onboarding, rules, history, - carousels, announcements, };