From 7836698dc27803e313dd13bf55cfefb78a37a225 Mon Sep 17 00:00:00 2001 From: Justin Date: Tue, 2 Aug 2022 09:20:07 -0400 Subject: [PATCH 1/8] Add React Query --- .../actions/__tests__/carousels.test.ts | 58 -------------- app/soapbox/actions/auth.ts | 4 +- app/soapbox/actions/carousels.ts | 25 ------ app/soapbox/containers/soapbox.tsx | 30 +++++--- app/soapbox/contexts/auth-context.tsx | 77 +++++++++++++++++++ .../features/feed-filtering/feed-carousel.tsx | 24 ++---- app/soapbox/features/ui/index.tsx | 72 +++++++++-------- app/soapbox/queries/carousels.ts | 29 +++++++ app/soapbox/queries/client.ts | 36 ++++++++- .../reducers/__tests__/carousels.test.ts | 50 ------------ app/soapbox/reducers/auth.js | 2 +- app/soapbox/reducers/carousels.ts | 38 --------- app/soapbox/reducers/index.ts | 2 - 13 files changed, 209 insertions(+), 238 deletions(-) delete mode 100644 app/soapbox/actions/__tests__/carousels.test.ts delete mode 100644 app/soapbox/actions/carousels.ts create mode 100644 app/soapbox/contexts/auth-context.tsx create mode 100644 app/soapbox/queries/carousels.ts delete mode 100644 app/soapbox/reducers/__tests__/carousels.test.ts delete mode 100644 app/soapbox/reducers/carousels.ts 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/auth.ts b/app/soapbox/actions/auth.ts index d3252e7fb..53209464e 100644 --- a/app/soapbox/actions/auth.ts +++ b/app/soapbox/actions/auth.ts @@ -202,9 +202,7 @@ export const rememberAuthAccount = (accountUrl: string) => export const loadCredentials = (token: string, accountUrl: string) => (dispatch: AppDispatch) => dispatch(rememberAuthAccount(accountUrl)) - .then(() => { - dispatch(verifyCredentials(token, accountUrl)); - }) + .then(() => dispatch(verifyCredentials(token, accountUrl))) .catch(() => dispatch(verifyCredentials(token, accountUrl))); /** Trim the username and strip the leading @. */ 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/containers/soapbox.tsx b/app/soapbox/containers/soapbox.tsx index 220e22af4..3de52e7a3 100644 --- a/app/soapbox/containers/soapbox.tsx +++ b/app/soapbox/containers/soapbox.tsx @@ -17,6 +17,7 @@ import * as BuildConfig from 'soapbox/build_config'; import GdprBanner from 'soapbox/components/gdpr-banner'; import Helmet from 'soapbox/components/helmet'; import LoadingScreen from 'soapbox/components/loading-screen'; +import { AuthProvider, useAuth } from 'soapbox/contexts/auth-context'; import AuthLayout from 'soapbox/features/auth_layout'; import PublicLayout from 'soapbox/features/public_layout'; import BundleContainer from 'soapbox/features/ui/containers/bundle_container'; @@ -38,7 +39,7 @@ import { useLocale, } from 'soapbox/hooks'; import MESSAGES from 'soapbox/locales/messages'; -import { queryClient } from 'soapbox/queries/client'; +import { queryClient, useAxiosInterceptors } from 'soapbox/queries/client'; import { useCachedLocationHandler } from 'soapbox/utils/redirect'; import { generateThemeCss } from 'soapbox/utils/theme'; @@ -62,7 +63,7 @@ const loadInitial = () => { // @ts-ignore return async(dispatch, getState) => { // Await for authenticated fetch - await dispatch(fetchMe()); + const account = await dispatch(fetchMe()); // Await for feature detection await dispatch(loadInstance()); // Await for configuration @@ -75,12 +76,15 @@ const loadInitial = () => { if (pepeEnabled && !state.me) { await dispatch(fetchVerificationConfig()); } + + return account; }; }; /** Highest level node with the Redux store. */ const SoapboxMount = () => { useCachedLocationHandler(); + const me = useAppSelector(state => state.me); const instance = useAppSelector(state => state.instance); const account = useOwnAccount(); @@ -195,6 +199,9 @@ interface ISoapboxLoad { /** Initial data loader. */ const SoapboxLoad: React.FC = ({ children }) => { const dispatch = useAppDispatch(); + const { setAccount, token, baseApiUri } = useAuth(); + + useAxiosInterceptors(token, baseApiUri); const me = useAppSelector(state => state.me); const account = useOwnAccount(); @@ -224,7 +231,8 @@ const SoapboxLoad: React.FC = ({ children }) => { // Load initial data from the API useEffect(() => { - dispatch(loadInitial()).then(() => { + dispatch(loadInitial()).then((account) => { + setAccount(account); setIsLoaded(true); }).catch(() => { setIsLoaded(true); @@ -282,15 +290,17 @@ const SoapboxHead: React.FC = ({ children }) => { /** The root React node of the application. */ const Soapbox: React.FC = () => { return ( - + - - - - - + + + + + + + - + ); }; diff --git a/app/soapbox/contexts/auth-context.tsx b/app/soapbox/contexts/auth-context.tsx new file mode 100644 index 000000000..7c023e92d --- /dev/null +++ b/app/soapbox/contexts/auth-context.tsx @@ -0,0 +1,77 @@ + +import React, { + createContext, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; + +import { localState } from 'soapbox/reducers/auth'; +import { parseBaseURL } from 'soapbox/utils/auth'; + +const AuthContext = createContext(null as any); + +interface IAccount { + acct: string + avatar: string + avatar_static: string + bot: boolean + created_at: string + discoverable: boolean + display_name: string + emojis: string[] + fields: any // not sure + followers_count: number + following_count: number + group: boolean + header: string + header_static: string + id: string + last_status_at: string + location: string + locked: boolean + note: string + statuses_count: number + url: string + username: string + verified: boolean + website: string +} + +export const AuthProvider: React.FC = ({ children }: { children: React.ReactNode }) => { + const [account, setAccount] = useState(); + const [token, setToken] = useState(); + const [baseApiUri, setBaseApiUri] = useState(); + + const value = useMemo(() => ({ + account, + baseApiUri, + setAccount, + token, + }), [account]); + + useEffect(() => { + const cachedAuth: any = localState?.toJS(); + + if (cachedAuth?.me) { + setToken(cachedAuth.users[cachedAuth.me].access_token); + setBaseApiUri(parseBaseURL(cachedAuth.users[cachedAuth.me].url)); + } + }, []); + + return ( + + {children} + + ); +}; + +interface IAuth { + account: IAccount + baseApiUri: string + setAccount(account: IAccount): void + token: string +} + +export const useAuth = (): IAuth => useContext(AuthContext); 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/features/ui/index.tsx b/app/soapbox/features/ui/index.tsx index b9fdab67d..58cf10efc 100644 --- a/app/soapbox/features/ui/index.tsx +++ b/app/soapbox/features/ui/index.tsx @@ -1,5 +1,6 @@ 'use strict'; +import { QueryClientProvider } from '@tanstack/react-query'; import debounce from 'lodash/debounce'; import React, { useState, useEffect, useRef, useCallback } from 'react'; import { HotKeys } from 'react-hotkeys'; @@ -119,6 +120,7 @@ import { WrappedRoute } from './util/react_router_helpers'; // Dummy import, to make sure that ends up in the application bundle. // Without this it ends up in ~8 very commonly used bundles. import 'soapbox/components/status'; +import { queryClient } from 'soapbox/queries/client'; const EmptyPage = HomePage; @@ -648,51 +650,53 @@ const UI: React.FC = ({ children }) => { }; return ( - -
- + + +
+ -
- +
+ - - - {!standalone && } - + + + {!standalone && } + - - {children} - - + + {children} + + - {me && floatingActionButton} + {me && floatingActionButton} - - {Component => } - + + {Component => } + - {me && ( - + {me && ( + + {Component => } + + )} + {me && features.chats && !mobile && ( + + {Component => } + + )} + + + {Component => } - )} - {me && features.chats && !mobile && ( - + + {Component => } - )} - - - - {Component => } - - - - {Component => } - +
-
-
+ +
); }; diff --git a/app/soapbox/queries/carousels.ts b/app/soapbox/queries/carousels.ts new file mode 100644 index 000000000..bf56ad0a7 --- /dev/null +++ b/app/soapbox/queries/carousels.ts @@ -0,0 +1,29 @@ +import { useQuery } from '@tanstack/react-query'; + +import API from './client'; + +type Avatar = { + account_id: string + account_avatar: string + username: string +} + +const getCarouselAvatars = async () => { + const { data } = await API.get('/api/v1/truth/carousels/avatars'); + return data; +}; + +export default function useCarouselAvatars(): { data: Avatar[], isFetching: boolean, isError: boolean, isSuccess: boolean } { + const { data, isFetching, isError, isSuccess } = useQuery(['carouselAvatars'], getCarouselAvatars, { + placeholderData: [], + }); + + const avatars = data as Avatar[]; + + return { + data: avatars, + isFetching, + isError, + isSuccess, + }; +} diff --git a/app/soapbox/queries/client.ts b/app/soapbox/queries/client.ts index d772e9288..beddfa773 100644 --- a/app/soapbox/queries/client.ts +++ b/app/soapbox/queries/client.ts @@ -1,4 +1,20 @@ import { QueryClient } from '@tanstack/react-query'; +import axios, { AxiosRequestConfig } from 'axios'; + +import * as BuildConfig from 'soapbox/build_config'; +import { isURL } from 'soapbox/utils/auth'; + +const maybeParseJSON = (data: string) => { + try { + return JSON.parse(data); + } catch (Exception) { + return data; + } +}; + +const API = axios.create({ + transformResponse: [maybeParseJSON], +}); const queryClient = new QueryClient({ defaultOptions: { @@ -10,4 +26,22 @@ const queryClient = new QueryClient({ }, }); -export { queryClient }; +const useAxiosInterceptors = (token: string, baseApiUri: string) => { + API.interceptors.request.use( + async(config: AxiosRequestConfig) => { + if (token) { + config.baseURL = isURL(BuildConfig.BACKEND_URL) ? BuildConfig.BACKEND_URL : baseApiUri; + // eslint-disable-next-line no-param-reassign + config.headers = { + ...config.headers, + Authorization: (token ? `Bearer ${token}` : null) as string | number | boolean | string[] | undefined, + } as any; + } + + return config; + }, + (error) => Promise.reject(error), + ); +}; + +export { API as default, queryClient, useAxiosInterceptors }; 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/auth.js b/app/soapbox/reducers/auth.js index 2f0c773cd..ad0da1a1d 100644 --- a/app/soapbox/reducers/auth.js +++ b/app/soapbox/reducers/auth.js @@ -38,7 +38,7 @@ const getSessionUser = () => { }; const sessionUser = getSessionUser(); -const localState = fromJS(JSON.parse(localStorage.getItem(STORAGE_KEY))); +export const localState = fromJS(JSON.parse(localStorage.getItem(STORAGE_KEY))); // Checks if the user has an ID and access token const validUser = user => { 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, }; From 7ac18c09518e86adab5bb36baa38220b78a5c2a6 Mon Sep 17 00:00:00 2001 From: Justin Date: Tue, 2 Aug 2022 09:28:48 -0400 Subject: [PATCH 2/8] Lint --- app/soapbox/features/ui/index.tsx | 2 +- app/soapbox/queries/carousels.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/soapbox/features/ui/index.tsx b/app/soapbox/features/ui/index.tsx index 58cf10efc..67f5d7500 100644 --- a/app/soapbox/features/ui/index.tsx +++ b/app/soapbox/features/ui/index.tsx @@ -36,6 +36,7 @@ import HomePage from 'soapbox/pages/home_page'; import ProfilePage from 'soapbox/pages/profile_page'; import RemoteInstancePage from 'soapbox/pages/remote_instance_page'; import StatusPage from 'soapbox/pages/status_page'; +import { queryClient } from 'soapbox/queries/client'; import { getAccessToken, getVapidKey } from 'soapbox/utils/auth'; import { isStandalone } from 'soapbox/utils/state'; // import GroupSidebarPanel from '../groups/sidebar_panel'; @@ -120,7 +121,6 @@ import { WrappedRoute } from './util/react_router_helpers'; // Dummy import, to make sure that ends up in the application bundle. // Without this it ends up in ~8 very commonly used bundles. import 'soapbox/components/status'; -import { queryClient } from 'soapbox/queries/client'; const EmptyPage = HomePage; diff --git a/app/soapbox/queries/carousels.ts b/app/soapbox/queries/carousels.ts index bf56ad0a7..36652e3cb 100644 --- a/app/soapbox/queries/carousels.ts +++ b/app/soapbox/queries/carousels.ts @@ -8,7 +8,7 @@ type Avatar = { username: string } -const getCarouselAvatars = async () => { +const getCarouselAvatars = async() => { const { data } = await API.get('/api/v1/truth/carousels/avatars'); return data; }; From 15f536e94920fbebcbde5ef484ac8b961ddbbdf8 Mon Sep 17 00:00:00 2001 From: Justin Date: Mon, 8 Aug 2022 11:13:43 -0400 Subject: [PATCH 3/8] Use redux auth for now --- app/soapbox/actions/auth.ts | 4 +- app/soapbox/api.ts | 2 +- app/soapbox/containers/soapbox.tsx | 37 ++++++------- app/soapbox/contexts/auth-context.tsx | 77 --------------------------- app/soapbox/reducers/auth.js | 2 +- 5 files changed, 22 insertions(+), 100 deletions(-) delete mode 100644 app/soapbox/contexts/auth-context.tsx diff --git a/app/soapbox/actions/auth.ts b/app/soapbox/actions/auth.ts index 53209464e..d3252e7fb 100644 --- a/app/soapbox/actions/auth.ts +++ b/app/soapbox/actions/auth.ts @@ -202,7 +202,9 @@ export const rememberAuthAccount = (accountUrl: string) => export const loadCredentials = (token: string, accountUrl: string) => (dispatch: AppDispatch) => dispatch(rememberAuthAccount(accountUrl)) - .then(() => dispatch(verifyCredentials(token, accountUrl))) + .then(() => { + dispatch(verifyCredentials(token, accountUrl)); + }) .catch(() => dispatch(verifyCredentials(token, accountUrl))); /** Trim the username and strip the leading @. */ diff --git a/app/soapbox/api.ts b/app/soapbox/api.ts index bdcaf53d8..261bc48a4 100644 --- a/app/soapbox/api.ts +++ b/app/soapbox/api.ts @@ -41,7 +41,7 @@ const maybeParseJSON = (data: string) => { } }; -const getAuthBaseURL = createSelector([ +export const getAuthBaseURL = createSelector([ (state: RootState, me: string | false | null) => state.accounts.getIn([me, 'url']), (state: RootState, _me: string | false | null) => state.auth.get('me'), ], (accountUrl, authUserUrl) => { diff --git a/app/soapbox/containers/soapbox.tsx b/app/soapbox/containers/soapbox.tsx index 3de52e7a3..1a87ec4e8 100644 --- a/app/soapbox/containers/soapbox.tsx +++ b/app/soapbox/containers/soapbox.tsx @@ -13,11 +13,11 @@ import { loadInstance } from 'soapbox/actions/instance'; import { fetchMe } from 'soapbox/actions/me'; import { loadSoapboxConfig, getSoapboxConfig } from 'soapbox/actions/soapbox'; import { fetchVerificationConfig } from 'soapbox/actions/verification'; +import { getAuthBaseURL } from 'soapbox/api'; import * as BuildConfig from 'soapbox/build_config'; import GdprBanner from 'soapbox/components/gdpr-banner'; import Helmet from 'soapbox/components/helmet'; import LoadingScreen from 'soapbox/components/loading-screen'; -import { AuthProvider, useAuth } from 'soapbox/contexts/auth-context'; import AuthLayout from 'soapbox/features/auth_layout'; import PublicLayout from 'soapbox/features/public_layout'; import BundleContainer from 'soapbox/features/ui/containers/bundle_container'; @@ -40,6 +40,7 @@ import { } from 'soapbox/hooks'; import MESSAGES from 'soapbox/locales/messages'; import { queryClient, useAxiosInterceptors } from 'soapbox/queries/client'; +import { getAccessToken } from 'soapbox/utils/auth'; import { useCachedLocationHandler } from 'soapbox/utils/redirect'; import { generateThemeCss } from 'soapbox/utils/theme'; @@ -63,7 +64,7 @@ const loadInitial = () => { // @ts-ignore return async(dispatch, getState) => { // Await for authenticated fetch - const account = await dispatch(fetchMe()); + await dispatch(fetchMe()); // Await for feature detection await dispatch(loadInstance()); // Await for configuration @@ -76,8 +77,6 @@ const loadInitial = () => { if (pepeEnabled && !state.me) { await dispatch(fetchVerificationConfig()); } - - return account; }; }; @@ -199,15 +198,16 @@ interface ISoapboxLoad { /** Initial data loader. */ const SoapboxLoad: React.FC = ({ children }) => { const dispatch = useAppDispatch(); - const { setAccount, token, baseApiUri } = useAuth(); - - useAxiosInterceptors(token, baseApiUri); const me = useAppSelector(state => state.me); const account = useOwnAccount(); const swUpdating = useAppSelector(state => state.meta.swUpdating); + const accessToken = useAppSelector((state) => getAccessToken(state)); + const baseURL = useAppSelector((state) => me ? getAuthBaseURL(state, me) : ''); const locale = useLocale(); + useAxiosInterceptors(accessToken, baseURL); + const [messages, setMessages] = useState>({}); const [localeLoading, setLocaleLoading] = useState(true); const [isLoaded, setIsLoaded] = useState(false); @@ -231,8 +231,7 @@ const SoapboxLoad: React.FC = ({ children }) => { // Load initial data from the API useEffect(() => { - dispatch(loadInitial()).then((account) => { - setAccount(account); + dispatch(loadInitial()).then(() => { setIsLoaded(true); }).catch(() => { setIsLoaded(true); @@ -290,17 +289,15 @@ const SoapboxHead: React.FC = ({ children }) => { /** The root React node of the application. */ const Soapbox: React.FC = () => { return ( - - - - - - - - - - - + + + + + + + + + ); }; diff --git a/app/soapbox/contexts/auth-context.tsx b/app/soapbox/contexts/auth-context.tsx deleted file mode 100644 index 7c023e92d..000000000 --- a/app/soapbox/contexts/auth-context.tsx +++ /dev/null @@ -1,77 +0,0 @@ - -import React, { - createContext, - useContext, - useEffect, - useMemo, - useState, -} from 'react'; - -import { localState } from 'soapbox/reducers/auth'; -import { parseBaseURL } from 'soapbox/utils/auth'; - -const AuthContext = createContext(null as any); - -interface IAccount { - acct: string - avatar: string - avatar_static: string - bot: boolean - created_at: string - discoverable: boolean - display_name: string - emojis: string[] - fields: any // not sure - followers_count: number - following_count: number - group: boolean - header: string - header_static: string - id: string - last_status_at: string - location: string - locked: boolean - note: string - statuses_count: number - url: string - username: string - verified: boolean - website: string -} - -export const AuthProvider: React.FC = ({ children }: { children: React.ReactNode }) => { - const [account, setAccount] = useState(); - const [token, setToken] = useState(); - const [baseApiUri, setBaseApiUri] = useState(); - - const value = useMemo(() => ({ - account, - baseApiUri, - setAccount, - token, - }), [account]); - - useEffect(() => { - const cachedAuth: any = localState?.toJS(); - - if (cachedAuth?.me) { - setToken(cachedAuth.users[cachedAuth.me].access_token); - setBaseApiUri(parseBaseURL(cachedAuth.users[cachedAuth.me].url)); - } - }, []); - - return ( - - {children} - - ); -}; - -interface IAuth { - account: IAccount - baseApiUri: string - setAccount(account: IAccount): void - token: string -} - -export const useAuth = (): IAuth => useContext(AuthContext); diff --git a/app/soapbox/reducers/auth.js b/app/soapbox/reducers/auth.js index ad0da1a1d..2f0c773cd 100644 --- a/app/soapbox/reducers/auth.js +++ b/app/soapbox/reducers/auth.js @@ -38,7 +38,7 @@ const getSessionUser = () => { }; const sessionUser = getSessionUser(); -export const localState = fromJS(JSON.parse(localStorage.getItem(STORAGE_KEY))); +const localState = fromJS(JSON.parse(localStorage.getItem(STORAGE_KEY))); // Checks if the user has an ID and access token const validUser = user => { From 6b297c3a7e058db1adda42039d3057d0e6fd233f Mon Sep 17 00:00:00 2001 From: Justin Date: Mon, 8 Aug 2022 15:53:21 -0400 Subject: [PATCH 4/8] Add tests --- .../__tests__/feed-carousel.test.tsx | 69 +++++++++---------- app/soapbox/jest/test-helpers.tsx | 33 ++++++++- .../queries/__tests__/carousels.test.ts | 45 ++++++++++++ app/soapbox/queries/carousels.ts | 14 ++-- 4 files changed, 115 insertions(+), 46 deletions(-) create mode 100644 app/soapbox/queries/__tests__/carousels.test.ts 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..c17ea2c5a 100644 --- a/app/soapbox/features/feed-filtering/__tests__/feed-carousel.test.tsx +++ b/app/soapbox/features/feed-filtering/__tests__/feed-carousel.test.tsx @@ -2,8 +2,7 @@ import userEvent from '@testing-library/user-event'; import { Map as ImmutableMap } from 'immutable'; import React from 'react'; -import { __stub } from '../../../api'; -import { render, screen, waitFor } from '../../../jest/test-helpers'; +import { mock, render, screen, waitFor } from '../../../jest/test-helpers'; import FeedCarousel from '../feed-carousel'; jest.mock('../../../hooks/useDimensions', () => ({ @@ -55,63 +54,63 @@ 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(() => { + 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: [], - }; + 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, - }; + 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 => { - 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' }, - ]); - }); + 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' }, + ]); Element.prototype.getBoundingClientRect = jest.fn(() => { return { diff --git a/app/soapbox/jest/test-helpers.tsx b/app/soapbox/jest/test-helpers.tsx index b7223caca..f48bf09f3 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 MockAdapter from 'axios-mock-adapter'; import { merge } from 'immutable'; import React, { FC, ReactElement } from 'react'; import { IntlProvider } from 'react-intl'; @@ -10,7 +11,7 @@ import { Action, applyMiddleware, createStore } from 'redux'; import thunk from 'redux-thunk'; import '@testing-library/jest-dom'; -import { queryClient } from 'soapbox/queries/client'; +import API from 'soapbox/queries/client'; import NotificationsContainer from '../features/ui/containers/notifications_container'; import { default as rootReducer } from '../reducers'; @@ -28,8 +29,26 @@ 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)); +const mock = new MockAdapter(API, { onNoMatch: 'throwException' }); +const queryClient = new QueryClient({ + logger: { + // eslint-disable-next-line no-console + log: console.log, + warn: console.warn, + error: () => { }, + }, + defaultOptions: { + queries: { + retry: false, + }, + }, +}); +beforeEach(() => { + mock.reset(); +}); + +const createTestStore = (initialState: any) => createStore(rootReducer, initialState, applyMiddleware(thunk)); const TestApp: FC = ({ children, storeProps, routerProps = {} }) => { let store: ReturnType; let appState = rootState; @@ -71,6 +90,12 @@ const customRender = ( ...options, }); +const queryWrapper: React.FC = ({ children }) => ( + + {children} + +); + const mockWindowProperty = (property: any, value: any) => { const { [property]: originalProperty } = window; delete window[property]; @@ -97,4 +122,6 @@ export { rootReducer, mockWindowProperty, createTestStore, + mock, + queryWrapper, }; diff --git a/app/soapbox/queries/__tests__/carousels.test.ts b/app/soapbox/queries/__tests__/carousels.test.ts new file mode 100644 index 000000000..61edbce11 --- /dev/null +++ b/app/soapbox/queries/__tests__/carousels.test.ts @@ -0,0 +1,45 @@ +import { renderHook } from '@testing-library/react-hooks'; + +import { mock, queryWrapper, waitFor } from 'soapbox/jest/test-helpers'; + +import useCarouselAvatars from '../carousels'; + +describe('useCarouselAvatars', () => { + describe('with a successul query', () => { + beforeEach(() => { + 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(), { + wrapper: queryWrapper, + }); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + expect(result.current.data?.length).toBe(4); + }); + }); + + describe('with an unsuccessul query', () => { + beforeEach(() => { + mock.onGet('/api/v1/truth/carousels/avatars').networkError(); + }); + + it('is successful', async() => { + const { result } = renderHook(() => useCarouselAvatars(), { + wrapper: queryWrapper, + }); + + 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 index 36652e3cb..2989f4a52 100644 --- a/app/soapbox/queries/carousels.ts +++ b/app/soapbox/queries/carousels.ts @@ -1,6 +1,6 @@ import { useQuery } from '@tanstack/react-query'; -import API from './client'; +import API from 'soapbox/queries/client'; type Avatar = { account_id: string @@ -13,17 +13,15 @@ const getCarouselAvatars = async() => { return data; }; -export default function useCarouselAvatars(): { data: Avatar[], isFetching: boolean, isError: boolean, isSuccess: boolean } { - const { data, isFetching, isError, isSuccess } = useQuery(['carouselAvatars'], getCarouselAvatars, { +export default function useCarouselAvatars() { + const result = useQuery(['carouselAvatars'], getCarouselAvatars, { placeholderData: [], }); - const avatars = data as Avatar[]; + const avatars = result.data; return { - data: avatars, - isFetching, - isError, - isSuccess, + ...result, + data: avatars || [], }; } From 8eec8f3a09313db7ebd45e228ac3791f0d0b4a1d Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 9 Aug 2022 10:24:43 -0500 Subject: [PATCH 5/8] react-query: use legacy API client with hooks --- app/soapbox/containers/soapbox.tsx | 16 +++++-------- app/soapbox/hooks/index.ts | 1 + app/soapbox/hooks/useApi.ts | 12 ++++++++++ app/soapbox/jest/test-helpers.tsx | 4 +--- app/soapbox/queries/carousels.ts | 14 +++++++----- app/soapbox/queries/client.ts | 36 +----------------------------- 6 files changed, 28 insertions(+), 55 deletions(-) create mode 100644 app/soapbox/hooks/useApi.ts diff --git a/app/soapbox/containers/soapbox.tsx b/app/soapbox/containers/soapbox.tsx index 1a87ec4e8..413673775 100644 --- a/app/soapbox/containers/soapbox.tsx +++ b/app/soapbox/containers/soapbox.tsx @@ -13,7 +13,6 @@ import { loadInstance } from 'soapbox/actions/instance'; import { fetchMe } from 'soapbox/actions/me'; import { loadSoapboxConfig, getSoapboxConfig } from 'soapbox/actions/soapbox'; import { fetchVerificationConfig } from 'soapbox/actions/verification'; -import { getAuthBaseURL } from 'soapbox/api'; import * as BuildConfig from 'soapbox/build_config'; import GdprBanner from 'soapbox/components/gdpr-banner'; import Helmet from 'soapbox/components/helmet'; @@ -39,8 +38,7 @@ import { useLocale, } from 'soapbox/hooks'; import MESSAGES from 'soapbox/locales/messages'; -import { queryClient, useAxiosInterceptors } from 'soapbox/queries/client'; -import { getAccessToken } from 'soapbox/utils/auth'; +import { queryClient } from 'soapbox/queries/client'; import { useCachedLocationHandler } from 'soapbox/utils/redirect'; import { generateThemeCss } from 'soapbox/utils/theme'; @@ -202,12 +200,8 @@ const SoapboxLoad: React.FC = ({ children }) => { const me = useAppSelector(state => state.me); const account = useOwnAccount(); const swUpdating = useAppSelector(state => state.meta.swUpdating); - const accessToken = useAppSelector((state) => getAccessToken(state)); - const baseURL = useAppSelector((state) => me ? getAuthBaseURL(state, me) : ''); const locale = useLocale(); - useAxiosInterceptors(accessToken, baseURL); - const [messages, setMessages] = useState>({}); const [localeLoading, setLocaleLoading] = useState(true); const [isLoaded, setIsLoaded] = useState(false); @@ -289,15 +283,15 @@ const SoapboxHead: React.FC = ({ children }) => { /** The root React node of the application. */ const Soapbox: React.FC = () => { return ( - - + + - - + + ); }; 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 f48bf09f3..e0761eb27 100644 --- a/app/soapbox/jest/test-helpers.tsx +++ b/app/soapbox/jest/test-helpers.tsx @@ -11,8 +11,6 @@ import { Action, applyMiddleware, createStore } from 'redux'; import thunk from 'redux-thunk'; import '@testing-library/jest-dom'; -import API from 'soapbox/queries/client'; - import NotificationsContainer from '../features/ui/containers/notifications_container'; import { default as rootReducer } from '../reducers'; @@ -29,7 +27,7 @@ const applyActions = (state: any, actions: any, reducer: any) => { return actions.reduce((state: any, action: any) => reducer(state, action), state); }; -const mock = new MockAdapter(API, { onNoMatch: 'throwException' }); +const mock = new MockAdapter(undefined as any, { onNoMatch: 'throwException' }); const queryClient = new QueryClient({ logger: { // eslint-disable-next-line no-console diff --git a/app/soapbox/queries/carousels.ts b/app/soapbox/queries/carousels.ts index 2989f4a52..7d295183e 100644 --- a/app/soapbox/queries/carousels.ts +++ b/app/soapbox/queries/carousels.ts @@ -1,6 +1,6 @@ import { useQuery } from '@tanstack/react-query'; -import API from 'soapbox/queries/client'; +import { useApi } from 'soapbox/hooks'; type Avatar = { account_id: string @@ -8,12 +8,14 @@ type Avatar = { username: string } -const getCarouselAvatars = async() => { - const { data } = await API.get('/api/v1/truth/carousels/avatars'); - return data; -}; - 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: [], }); diff --git a/app/soapbox/queries/client.ts b/app/soapbox/queries/client.ts index beddfa773..d772e9288 100644 --- a/app/soapbox/queries/client.ts +++ b/app/soapbox/queries/client.ts @@ -1,20 +1,4 @@ import { QueryClient } from '@tanstack/react-query'; -import axios, { AxiosRequestConfig } from 'axios'; - -import * as BuildConfig from 'soapbox/build_config'; -import { isURL } from 'soapbox/utils/auth'; - -const maybeParseJSON = (data: string) => { - try { - return JSON.parse(data); - } catch (Exception) { - return data; - } -}; - -const API = axios.create({ - transformResponse: [maybeParseJSON], -}); const queryClient = new QueryClient({ defaultOptions: { @@ -26,22 +10,4 @@ const queryClient = new QueryClient({ }, }); -const useAxiosInterceptors = (token: string, baseApiUri: string) => { - API.interceptors.request.use( - async(config: AxiosRequestConfig) => { - if (token) { - config.baseURL = isURL(BuildConfig.BACKEND_URL) ? BuildConfig.BACKEND_URL : baseApiUri; - // eslint-disable-next-line no-param-reassign - config.headers = { - ...config.headers, - Authorization: (token ? `Bearer ${token}` : null) as string | number | boolean | string[] | undefined, - } as any; - } - - return config; - }, - (error) => Promise.reject(error), - ); -}; - -export { API as default, queryClient, useAxiosInterceptors }; +export { queryClient }; From 28d54612959dac8ba5b2ce1ac3aa505762dcd8a5 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 9 Aug 2022 11:00:44 -0500 Subject: [PATCH 6/8] Fix react-query tests --- .../__tests__/feed-carousel.test.tsx | 40 +++++++++++-------- app/soapbox/jest/test-helpers.tsx | 27 +++++++------ .../queries/__tests__/carousels.test.ts | 37 +++++++++-------- 3 files changed, 55 insertions(+), 49 deletions(-) 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 c17ea2c5a..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,9 @@ import userEvent from '@testing-library/user-event'; import { Map as ImmutableMap } from 'immutable'; import React from 'react'; -import { mock, render, screen, waitFor } from '../../../jest/test-helpers'; +import { __stub } from 'soapbox/api'; + +import { render, screen, waitFor } from '../../../jest/test-helpers'; import FeedCarousel from '../feed-carousel'; jest.mock('../../../hooks/useDimensions', () => ({ @@ -56,13 +58,15 @@ describe('', () => { describe('with avatars', () => { beforeEach(() => { - 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' }, - ]); + __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('should render the Carousel', async() => { @@ -76,7 +80,7 @@ describe('', () => { describe('with 0 avatars', () => { beforeEach(() => { - mock.onGet('/api/v1/truth/carousels/avatars').reply(200, []); + __stub((mock) => mock.onGet('/api/v1/truth/carousels/avatars').reply(200, [])); }); it('renders nothing', async() => { @@ -90,7 +94,7 @@ describe('', () => { describe('with a failed request to the API', () => { beforeEach(() => { - mock.onGet('/api/v1/truth/carousels/avatars').networkError(); + __stub((mock) => mock.onGet('/api/v1/truth/carousels/avatars').networkError()); }); it('renders the error message', async() => { @@ -104,13 +108,15 @@ describe('', () => { describe('with multiple pages of avatars', () => { beforeEach(() => { - 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' }, - ]); + __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' }, + ]); + }); Element.prototype.getBoundingClientRect = jest.fn(() => { return { diff --git a/app/soapbox/jest/test-helpers.tsx b/app/soapbox/jest/test-helpers.tsx index e0761eb27..0894d4d40 100644 --- a/app/soapbox/jest/test-helpers.tsx +++ b/app/soapbox/jest/test-helpers.tsx @@ -1,7 +1,7 @@ import { configureMockStore } from '@jedmao/redux-mock-store'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { render, RenderOptions } from '@testing-library/react'; -import MockAdapter from 'axios-mock-adapter'; +import { renderHook, RenderHookOptions } from '@testing-library/react-hooks'; import { merge } from 'immutable'; import React, { FC, ReactElement } from 'react'; import { IntlProvider } from 'react-intl'; @@ -27,7 +27,7 @@ const applyActions = (state: any, actions: any, reducer: any) => { return actions.reduce((state: any, action: any) => reducer(state, action), state); }; -const mock = new MockAdapter(undefined as any, { onNoMatch: 'throwException' }); +/** React Query client for tests. */ const queryClient = new QueryClient({ logger: { // eslint-disable-next-line no-console @@ -42,10 +42,6 @@ const queryClient = new QueryClient({ }, }); -beforeEach(() => { - mock.reset(); -}); - const createTestStore = (initialState: any) => createStore(rootReducer, initialState, applyMiddleware(thunk)); const TestApp: FC = ({ children, storeProps, routerProps = {} }) => { let store: ReturnType; @@ -88,11 +84,17 @@ const customRender = ( ...options, }); -const queryWrapper: React.FC = ({ children }) => ( - - {children} - -); +/** 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; @@ -114,12 +116,11 @@ const mockWindowProperty = (property: any, value: any) => { export * from '@testing-library/react'; export { customRender as render, + customRenderHook as renderHook, mockStore, applyActions, rootState, rootReducer, mockWindowProperty, createTestStore, - mock, - queryWrapper, }; diff --git a/app/soapbox/queries/__tests__/carousels.test.ts b/app/soapbox/queries/__tests__/carousels.test.ts index 61edbce11..9ee5fa4c2 100644 --- a/app/soapbox/queries/__tests__/carousels.test.ts +++ b/app/soapbox/queries/__tests__/carousels.test.ts @@ -1,25 +1,24 @@ -import { renderHook } from '@testing-library/react-hooks'; - -import { mock, queryWrapper, waitFor } from 'soapbox/jest/test-helpers'; +import { __stub } from 'soapbox/api'; +import { renderHook, waitFor } from 'soapbox/jest/test-helpers'; import useCarouselAvatars from '../carousels'; describe('useCarouselAvatars', () => { - describe('with a successul query', () => { + describe('with a successful query', () => { beforeEach(() => { - 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' }, - ]); + __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(), { - wrapper: queryWrapper, - }); + const { result } = renderHook(() => useCarouselAvatars()); await waitFor(() => expect(result.current.isFetching).toBe(false)); @@ -27,15 +26,15 @@ describe('useCarouselAvatars', () => { }); }); - describe('with an unsuccessul query', () => { + describe('with an unsuccessful query', () => { beforeEach(() => { - mock.onGet('/api/v1/truth/carousels/avatars').networkError(); + __stub((mock) => { + mock.onGet('/api/v1/truth/carousels/avatars').networkError(); + }); }); it('is successful', async() => { - const { result } = renderHook(() => useCarouselAvatars(), { - wrapper: queryWrapper, - }); + const { result } = renderHook(() => useCarouselAvatars()); await waitFor(() => expect(result.current.isFetching).toBe(false)); From 5f2532c3d7b4217f0df30be59a241a6a2838ddb5 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 9 Aug 2022 11:07:45 -0500 Subject: [PATCH 7/8] Throw out unnedeed QueryClientProvider changes --- app/soapbox/containers/soapbox.tsx | 1 - app/soapbox/features/ui/index.tsx | 72 ++++++++++++++---------------- 2 files changed, 34 insertions(+), 39 deletions(-) diff --git a/app/soapbox/containers/soapbox.tsx b/app/soapbox/containers/soapbox.tsx index 413673775..220e22af4 100644 --- a/app/soapbox/containers/soapbox.tsx +++ b/app/soapbox/containers/soapbox.tsx @@ -81,7 +81,6 @@ const loadInitial = () => { /** Highest level node with the Redux store. */ const SoapboxMount = () => { useCachedLocationHandler(); - const me = useAppSelector(state => state.me); const instance = useAppSelector(state => state.instance); const account = useOwnAccount(); diff --git a/app/soapbox/features/ui/index.tsx b/app/soapbox/features/ui/index.tsx index 67f5d7500..b9fdab67d 100644 --- a/app/soapbox/features/ui/index.tsx +++ b/app/soapbox/features/ui/index.tsx @@ -1,6 +1,5 @@ 'use strict'; -import { QueryClientProvider } from '@tanstack/react-query'; import debounce from 'lodash/debounce'; import React, { useState, useEffect, useRef, useCallback } from 'react'; import { HotKeys } from 'react-hotkeys'; @@ -36,7 +35,6 @@ import HomePage from 'soapbox/pages/home_page'; import ProfilePage from 'soapbox/pages/profile_page'; import RemoteInstancePage from 'soapbox/pages/remote_instance_page'; import StatusPage from 'soapbox/pages/status_page'; -import { queryClient } from 'soapbox/queries/client'; import { getAccessToken, getVapidKey } from 'soapbox/utils/auth'; import { isStandalone } from 'soapbox/utils/state'; // import GroupSidebarPanel from '../groups/sidebar_panel'; @@ -650,53 +648,51 @@ const UI: React.FC = ({ children }) => { }; return ( - - -
- + +
+ -
- +
+ - - - {!standalone && } - + + + {!standalone && } + - - {children} - - + + {children} + + - {me && floatingActionButton} + {me && floatingActionButton} - - {Component => } - + + {Component => } + - {me && ( - - {Component => } - - )} - {me && features.chats && !mobile && ( - - {Component => } - - )} - - - + {me && ( + {Component => } - - + )} + {me && features.chats && !mobile && ( + {Component => } -
+ )} + + + + {Component => } + + + + {Component => } +
- - +
+
); }; From 8d089805eaca80b728c8bc675ca65d34bc06fc05 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 9 Aug 2022 11:15:22 -0500 Subject: [PATCH 8/8] Don't export getAuthBaseURL --- app/soapbox/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/soapbox/api.ts b/app/soapbox/api.ts index 261bc48a4..bdcaf53d8 100644 --- a/app/soapbox/api.ts +++ b/app/soapbox/api.ts @@ -41,7 +41,7 @@ const maybeParseJSON = (data: string) => { } }; -export const getAuthBaseURL = createSelector([ +const getAuthBaseURL = createSelector([ (state: RootState, me: string | false | null) => state.accounts.getIn([me, 'url']), (state: RootState, _me: string | false | null) => state.auth.get('me'), ], (accountUrl, authUserUrl) => {