Add React Query

environments/review-react-quer-1t63ue/deployments/714
Justin 2022-08-02 09:20:07 -04:00
rodzic a8577fe0ee
commit 7836698dc2
13 zmienionych plików z 209 dodań i 238 usunięć

Wyświetl plik

@ -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<typeof mockStore>;
beforeEach(() => {
store = mockStore(rootState);
});
describe('with a successful API request', () => {
let avatars: Record<string, any>[];
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);
});
});
});

Wyświetl plik

@ -202,9 +202,7 @@ export const rememberAuthAccount = (accountUrl: string) =>
export const loadCredentials = (token: string, accountUrl: string) => export const loadCredentials = (token: string, accountUrl: string) =>
(dispatch: AppDispatch) => dispatch(rememberAuthAccount(accountUrl)) (dispatch: AppDispatch) => dispatch(rememberAuthAccount(accountUrl))
.then(() => { .then(() => dispatch(verifyCredentials(token, accountUrl)))
dispatch(verifyCredentials(token, accountUrl));
})
.catch(() => dispatch(verifyCredentials(token, accountUrl))); .catch(() => dispatch(verifyCredentials(token, accountUrl)));
/** Trim the username and strip the leading @. */ /** Trim the username and strip the leading @. */

Wyświetl plik

@ -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,
};

Wyświetl plik

@ -17,6 +17,7 @@ import * as BuildConfig from 'soapbox/build_config';
import GdprBanner from 'soapbox/components/gdpr-banner'; import GdprBanner from 'soapbox/components/gdpr-banner';
import Helmet from 'soapbox/components/helmet'; import Helmet from 'soapbox/components/helmet';
import LoadingScreen from 'soapbox/components/loading-screen'; import LoadingScreen from 'soapbox/components/loading-screen';
import { AuthProvider, useAuth } from 'soapbox/contexts/auth-context';
import AuthLayout from 'soapbox/features/auth_layout'; import AuthLayout from 'soapbox/features/auth_layout';
import PublicLayout from 'soapbox/features/public_layout'; import PublicLayout from 'soapbox/features/public_layout';
import BundleContainer from 'soapbox/features/ui/containers/bundle_container'; import BundleContainer from 'soapbox/features/ui/containers/bundle_container';
@ -38,7 +39,7 @@ import {
useLocale, useLocale,
} from 'soapbox/hooks'; } from 'soapbox/hooks';
import MESSAGES from 'soapbox/locales/messages'; 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 { useCachedLocationHandler } from 'soapbox/utils/redirect';
import { generateThemeCss } from 'soapbox/utils/theme'; import { generateThemeCss } from 'soapbox/utils/theme';
@ -62,7 +63,7 @@ const loadInitial = () => {
// @ts-ignore // @ts-ignore
return async(dispatch, getState) => { return async(dispatch, getState) => {
// Await for authenticated fetch // Await for authenticated fetch
await dispatch(fetchMe()); const account = await dispatch(fetchMe());
// Await for feature detection // Await for feature detection
await dispatch(loadInstance()); await dispatch(loadInstance());
// Await for configuration // Await for configuration
@ -75,12 +76,15 @@ const loadInitial = () => {
if (pepeEnabled && !state.me) { if (pepeEnabled && !state.me) {
await dispatch(fetchVerificationConfig()); await dispatch(fetchVerificationConfig());
} }
return account;
}; };
}; };
/** Highest level node with the Redux store. */ /** Highest level node with the Redux store. */
const SoapboxMount = () => { const SoapboxMount = () => {
useCachedLocationHandler(); useCachedLocationHandler();
const me = useAppSelector(state => state.me); const me = useAppSelector(state => state.me);
const instance = useAppSelector(state => state.instance); const instance = useAppSelector(state => state.instance);
const account = useOwnAccount(); const account = useOwnAccount();
@ -195,6 +199,9 @@ interface ISoapboxLoad {
/** Initial data loader. */ /** Initial data loader. */
const SoapboxLoad: React.FC<ISoapboxLoad> = ({ children }) => { const SoapboxLoad: React.FC<ISoapboxLoad> = ({ children }) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { setAccount, token, baseApiUri } = useAuth();
useAxiosInterceptors(token, baseApiUri);
const me = useAppSelector(state => state.me); const me = useAppSelector(state => state.me);
const account = useOwnAccount(); const account = useOwnAccount();
@ -224,7 +231,8 @@ const SoapboxLoad: React.FC<ISoapboxLoad> = ({ children }) => {
// Load initial data from the API // Load initial data from the API
useEffect(() => { useEffect(() => {
dispatch(loadInitial()).then(() => { dispatch(loadInitial()).then((account) => {
setAccount(account);
setIsLoaded(true); setIsLoaded(true);
}).catch(() => { }).catch(() => {
setIsLoaded(true); setIsLoaded(true);
@ -282,15 +290,17 @@ const SoapboxHead: React.FC<ISoapboxHead> = ({ children }) => {
/** The root React node of the application. */ /** The root React node of the application. */
const Soapbox: React.FC = () => { const Soapbox: React.FC = () => {
return ( return (
<Provider store={store}> <AuthProvider>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<SoapboxHead> <Provider store={store}>
<SoapboxLoad> <SoapboxHead>
<SoapboxMount /> <SoapboxLoad>
</SoapboxLoad> <SoapboxMount />
</SoapboxHead> </SoapboxLoad>
</SoapboxHead>
</Provider>
</QueryClientProvider> </QueryClientProvider>
</Provider> </AuthProvider>
); );
}; };

Wyświetl plik

@ -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<any> = ({ children }: { children: React.ReactNode }) => {
const [account, setAccount] = useState<IAccount>();
const [token, setToken] = useState<string>();
const [baseApiUri, setBaseApiUri] = useState<string>();
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 (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
};
interface IAuth {
account: IAccount
baseApiUri: string
setAccount(account: IAccount): void
token: string
}
export const useAuth = (): IAuth => useContext(AuthContext);

Wyświetl plik

@ -2,9 +2,9 @@ import classNames from 'classnames';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { fetchCarouselAvatars } from 'soapbox/actions/carousels';
import { replaceHomeTimeline } from 'soapbox/actions/timelines'; import { replaceHomeTimeline } from 'soapbox/actions/timelines';
import { useAppDispatch, useAppSelector, useDimensions, useFeatures } from 'soapbox/hooks'; import { useAppDispatch, useAppSelector, useDimensions, useFeatures } from 'soapbox/hooks';
import useCarouselAvatars from 'soapbox/queries/carousels';
import { Card, HStack, Icon, Stack, Text } from '../../components/ui'; import { Card, HStack, Icon, Stack, Text } from '../../components/ui';
import PlaceholderAvatar from '../placeholder/components/placeholder_avatar'; 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 selectedAccountId = useAppSelector(state => state.timelines.get('home')?.feedAccountId);
const isSelected = avatar.account_id === selectedAccountId; const isSelected = avatar.account_id === selectedAccountId;
const [isLoading, setLoading] = useState<boolean>(false); const [isFetching, setLoading] = useState<boolean>(false);
const handleClick = () => { const handleClick = () => {
if (isLoading) { if (isFetching) {
return; return;
} }
@ -32,7 +32,7 @@ const CarouselItem = ({ avatar }: { avatar: any }) => {
}; };
return ( return (
<div aria-disabled={isLoading} onClick={handleClick} className='cursor-pointer' role='filter-feed-by-user'> <div aria-disabled={isFetching} onClick={handleClick} className='cursor-pointer' role='filter-feed-by-user'>
<Stack className='w-16 h-auto' space={3}> <Stack className='w-16 h-auto' space={3}>
<div className='block mx-auto relative w-14 h-14 rounded-full'> <div className='block mx-auto relative w-14 h-14 rounded-full'>
{isSelected && ( {isSelected && (
@ -59,17 +59,15 @@ const CarouselItem = ({ avatar }: { avatar: any }) => {
}; };
const FeedCarousel = () => { const FeedCarousel = () => {
const dispatch = useAppDispatch();
const features = useFeatures(); const features = useFeatures();
const { data: avatars, isFetching, isError } = useCarouselAvatars();
const [cardRef, setCardRef, { width }] = useDimensions(); const [cardRef, setCardRef, { width }] = useDimensions();
const [pageSize, setPageSize] = useState<number>(0); const [pageSize, setPageSize] = useState<number>(0);
const [currentPage, setCurrentPage] = useState<number>(1); const [currentPage, setCurrentPage] = useState<number>(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 numberOfPages = Math.ceil(avatars.length / pageSize);
const widthPerAvatar = (cardRef?.scrollWidth || 0) / avatars.length; const widthPerAvatar = (cardRef?.scrollWidth || 0) / avatars.length;
@ -85,17 +83,11 @@ const FeedCarousel = () => {
} }
}, [width, widthPerAvatar]); }, [width, widthPerAvatar]);
useEffect(() => {
if (features.feedUserFiltering) {
dispatch(fetchCarouselAvatars());
}
}, []);
if (!features.feedUserFiltering) { if (!features.feedUserFiltering) {
return null; return null;
} }
if (hasError) { if (isError) {
return ( return (
<Card variant='rounded' size='lg' data-testid='feed-carousel-error'> <Card variant='rounded' size='lg' data-testid='feed-carousel-error'>
<Text align='center'> <Text align='center'>
@ -133,7 +125,7 @@ const FeedCarousel = () => {
style={{ transform: `translateX(-${(currentPage - 1) * 100}%)` }} style={{ transform: `translateX(-${(currentPage - 1) * 100}%)` }}
ref={setCardRef} ref={setCardRef}
> >
{isLoading ? ( {isFetching ? (
new Array(pageSize).fill(0).map((_, idx) => ( new Array(pageSize).fill(0).map((_, idx) => (
<div className='w-16 text-center' key={idx}> <div className='w-16 text-center' key={idx}>
<PlaceholderAvatar size={56} withText /> <PlaceholderAvatar size={56} withText />

Wyświetl plik

@ -1,5 +1,6 @@
'use strict'; 'use strict';
import { QueryClientProvider } from '@tanstack/react-query';
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import React, { useState, useEffect, useRef, useCallback } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import { HotKeys } from 'react-hotkeys'; import { HotKeys } from 'react-hotkeys';
@ -119,6 +120,7 @@ import { WrappedRoute } from './util/react_router_helpers';
// Dummy import, to make sure that <Status /> ends up in the application bundle. // Dummy import, to make sure that <Status /> ends up in the application bundle.
// Without this it ends up in ~8 very commonly used bundles. // Without this it ends up in ~8 very commonly used bundles.
import 'soapbox/components/status'; import 'soapbox/components/status';
import { queryClient } from 'soapbox/queries/client';
const EmptyPage = HomePage; const EmptyPage = HomePage;
@ -648,51 +650,53 @@ const UI: React.FC = ({ children }) => {
}; };
return ( return (
<HotKeys keyMap={keyMap} handlers={me ? handlers : undefined} ref={setHotkeysRef} attach={window} focused> <QueryClientProvider client={queryClient}>
<div ref={node} style={style}> <HotKeys keyMap={keyMap} handlers={me ? handlers : undefined} ref={setHotkeysRef} attach={window} focused>
<BackgroundShapes /> <div ref={node} style={style}>
<BackgroundShapes />
<div className='z-10 flex flex-col'> <div className='z-10 flex flex-col'>
<Navbar /> <Navbar />
<Layout> <Layout>
<Layout.Sidebar> <Layout.Sidebar>
{!standalone && <SidebarNavigation />} {!standalone && <SidebarNavigation />}
</Layout.Sidebar> </Layout.Sidebar>
<SwitchingColumnsArea> <SwitchingColumnsArea>
{children} {children}
</SwitchingColumnsArea> </SwitchingColumnsArea>
</Layout> </Layout>
{me && floatingActionButton} {me && floatingActionButton}
<BundleContainer fetchComponent={UploadArea}> <BundleContainer fetchComponent={UploadArea}>
{Component => <Component active={draggingOver} onClose={closeUploadModal} />} {Component => <Component active={draggingOver} onClose={closeUploadModal} />}
</BundleContainer> </BundleContainer>
{me && ( {me && (
<BundleContainer fetchComponent={SidebarMenu}> <BundleContainer fetchComponent={SidebarMenu}>
{Component => <Component />}
</BundleContainer>
)}
{me && features.chats && !mobile && (
<BundleContainer fetchComponent={ChatPanes}>
{Component => <Component />}
</BundleContainer>
)}
<ThumbNavigation />
<BundleContainer fetchComponent={ProfileHoverCard}>
{Component => <Component />} {Component => <Component />}
</BundleContainer> </BundleContainer>
)}
{me && features.chats && !mobile && ( <BundleContainer fetchComponent={StatusHoverCard}>
<BundleContainer fetchComponent={ChatPanes}>
{Component => <Component />} {Component => <Component />}
</BundleContainer> </BundleContainer>
)} </div>
<ThumbNavigation />
<BundleContainer fetchComponent={ProfileHoverCard}>
{Component => <Component />}
</BundleContainer>
<BundleContainer fetchComponent={StatusHoverCard}>
{Component => <Component />}
</BundleContainer>
</div> </div>
</div> </HotKeys>
</HotKeys> </QueryClientProvider>
); );
}; };

Wyświetl plik

@ -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<Avatar[]>(['carouselAvatars'], getCarouselAvatars, {
placeholderData: [],
});
const avatars = data as Avatar[];
return {
data: avatars,
isFetching,
isError,
isSuccess,
};
}

Wyświetl plik

@ -1,4 +1,20 @@
import { QueryClient } from '@tanstack/react-query'; 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({ const queryClient = new QueryClient({
defaultOptions: { 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 };

Wyświetl plik

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

Wyświetl plik

@ -38,7 +38,7 @@ const getSessionUser = () => {
}; };
const sessionUser = 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 // Checks if the user has an ID and access token
const validUser = user => { const validUser = user => {

Wyświetl plik

@ -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;
}
}

Wyświetl plik

@ -15,7 +15,6 @@ import aliases from './aliases';
import announcements from './announcements'; import announcements from './announcements';
import auth from './auth'; import auth from './auth';
import backups from './backups'; import backups from './backups';
import carousels from './carousels';
import chat_message_lists from './chat_message_lists'; import chat_message_lists from './chat_message_lists';
import chat_messages from './chat_messages'; import chat_messages from './chat_messages';
import chats from './chats'; import chats from './chats';
@ -124,7 +123,6 @@ const reducers = {
onboarding, onboarding,
rules, rules,
history, history,
carousels,
announcements, announcements,
}; };