Misc typing improvements++

Turned on compilerOptions.strict in tsconfig.json
and and did a bunch of ad hoc changes. Typed most of the implicit anys

 - Remove redux 4 to 5 migration
 - Stop prop drilling user state
 - Add more data types
 - Start typing redux state
 - Started using redux hooks in Dusken.tsx in favor of the connect HOC
This commit is contained in:
nikolark committed 2024-07-07 21:52:48 +02:00
1 parent ce43893f9d
commit 7a1fb19f56
18 files changed
+177 -122

No files matched your search

+14 -4
View File
@@ -50,6 +50,7 @@
"@testing-library/react-native": "^12.3.0",
"@types/jest": "^29.5.12",
"@types/react": "18.2",
"@types/react-native-confetti": "^0.1.4",
"eslint": "^8.57.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-typescript": "^18.0.0",
@@ -62,7 +63,7 @@
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-unused-imports": "^3.2.0",
"prettier": "^3.3.2",
"typescript": "^5.5.3"
"typescript": "~5.3.3"
}
},
"node_modules/@aashutoshrathi/word-wrap": {
@@ -8097,6 +8098,15 @@
"@types/react": "*"
}
},
"node_modules/@types/react-native-confetti": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/@types/react-native-confetti/-/react-native-confetti-0.1.4.tgz",
"integrity": "sha512-StgW1o0dw8ZLCVZ7v6ZVOBu+RYT+PFjtQAhF9k7uLn/avor4UtKhB9cDrF377+JfRw0aHeo3gg9dawZNzM/jOg==",
"dev": true,
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/semver": {
"version": "7.5.8",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz",
@@ -19617,9 +19627,9 @@
}
},
"node_modules/typescript": {
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz",
"integrity": "sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==",
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz",
"integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==",
"dev": true,
"bin": {
"tsc": "bin/tsc",
+2 -1
View File
@@ -54,6 +54,7 @@
"@testing-library/react-native": "^12.3.0",
"@types/jest": "^29.5.12",
"@types/react": "18.2",
"@types/react-native-confetti": "^0.1.4",
"eslint": "^8.57.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-typescript": "^18.0.0",
@@ -66,7 +67,7 @@
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-unused-imports": "^3.2.0",
"prettier": "^3.3.2",
"typescript": "^5.5.3"
"typescript": "~5.3.3"
},
"private": true
}
+1 -1
View File
@@ -31,7 +31,7 @@ export default function App() {
{/* FIXME: Read urlScheme and merchantIdentifier from .Config */}
<StripeProvider
publishableKey={process.env.EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY || ''}
urlScheme={Constants.expoConfig.scheme as string}
urlScheme={Constants.expoConfig?.scheme as string}
merchantIdentifier="merchant.no.neuf.chateau" // https://developer.apple.com/account/resources/identifiers/list/merchant
>
<Dusken />
+11 -22
View File
@@ -1,22 +1,20 @@
import { NativeBaseProvider } from 'native-base';
import React, { useEffect } from 'react';
import { connect } from 'react-redux';
import Navigation from './Navigation';
import { logout as logoutAction, registerUserSuccess as registerUserSuccessAction } from './actions';
import { migrateReduxPersistFourToFive } from './utils';
import { logout } from './actions';
import { useAppDispatch, useAppSelector } from './hooks';
const Dusken = () => {
const user = useAppSelector((store) => store.user);
const userToken = useAppSelector((store) => store.userToken);
const dispatch = useAppDispatch();
const DuskenContainer = ({ registerUserSuccess, userToken, user, logout }) => {
useEffect(() => {
async function inner() {
await migrateReduxPersistFourToFive(registerUserSuccess);
// logout authenticated users without token
if (user && !userToken) {
logout();
}
// logout authenticated users without token
if (user && !userToken) {
dispatch(logout());
}
inner();
});
return (
@@ -26,13 +24,4 @@ const DuskenContainer = ({ registerUserSuccess, userToken, user, logout }) => {
);
};
const mapStateToProps = (state) => ({
userToken: state.userToken,
user: state.user,
});
const mapDispatchToProps = {
registerUserSuccess: registerUserSuccessAction,
logout: logoutAction,
};
export default connect(mapStateToProps, mapDispatchToProps)(DuskenContainer);
export default Dusken;
+12 -4
View File
@@ -1,5 +1,5 @@
import { SimpleLineIcons as Icon } from '@expo/vector-icons';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { BottomTabNavigationOptions, createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import React from 'react';
@@ -57,10 +57,18 @@ const UserStackScreen = () => (
</UserStack.Navigator>
);
type TabBarIconType = BottomTabNavigationOptions['tabBarIcon'];
const HomeTabs = createBottomTabNavigator<HomeTabParamList>();
const EventIcon = ({ color, size }) => <Icon name="event" size={size} color={color} style={{ marginTop: 5 }} />;
const UserIcon = ({ color, size }) => <Icon name="user" size={size} color={color} style={{ marginTop: 5 }} />;
const AboutIcon = ({ color, size }) => <Icon name="info" size={size} color={color} style={{ marginTop: 5 }} />;
const EventIcon: TabBarIconType = ({ color, size }) => (
<Icon name="event" size={size} color={color} style={{ marginTop: 5 }} />
);
const UserIcon: TabBarIconType = ({ color, size }) => (
<Icon name="user" size={size} color={color} style={{ marginTop: 5 }} />
);
const AboutIcon: TabBarIconType = ({ color, size }) => (
<Icon name="info" size={size} color={color} style={{ marginTop: 5 }} />
);
const Navigation = () => (
<NavigationContainer>
<HomeTabs.Navigator initialRouteName="EventStack">
+1 -1
View File
@@ -18,7 +18,7 @@ function loginRequest() {
};
}
function loginSuccess(username, token) {
function loginSuccess(username: string, token: string) {
return {
type: LOGIN_SUCCESS,
username,
+9 -8
View File
@@ -13,10 +13,11 @@ import {
membershipChargeSuccess,
} from './actions';
import type { UserRegisterFormValues } from './modules/register/useUserRegister';
import { AppDispatch } from './reduxStore';
import { fetchWithTimeout } from './utils';
export function requestLogin(username, password) {
return (dispatch) => {
export function requestLogin(username: string, password: string) {
return (dispatch: AppDispatch) => {
// We are now logging in
dispatch(loginRequest());
@@ -50,8 +51,8 @@ export function requestLogin(username, password) {
};
}
export function requestUserData(authToken) {
return (dispatch) => {
export function requestUserData(authToken: string) {
return (dispatch: AppDispatch) => {
// We are now fetching user data
dispatch(userDataRequest());
@@ -82,7 +83,7 @@ export function requestUserData(authToken) {
}
export function requestRegisterUser({ firstName, lastName, email, phoneNumber, password }: UserRegisterFormValues) {
return (dispatch) => {
return (dispatch: AppDispatch) => {
dispatch(registerUserRequest());
return fetchWithTimeout(`${process.env.EXPO_PUBLIC_DUSKEN_API_URL}/api/user/register/`, {
@@ -117,8 +118,8 @@ export function requestRegisterUser({ firstName, lastName, email, phoneNumber, p
};
}
export function requestMembershipCharge(authToken, stripeToken, membershipType) {
return (dispatch) => {
export function requestMembershipCharge(authToken: string, stripeToken: string, membershipType: string) {
return (dispatch: AppDispatch) => {
dispatch(membershipChargeRequest());
return fetchWithTimeout(`${process.env.EXPO_PUBLIC_DUSKEN_API_URL}/api/membership/charge/`, {
@@ -153,7 +154,7 @@ export function requestMembershipCharge(authToken, stripeToken, membershipType)
}
/** Create a stripe payment sheet. Note that this is not using redux */
export async function requestStripePaymentSheet(authToken, membershipType) {
export async function requestStripePaymentSheet(authToken: string, membershipType: string) {
const body = JSON.stringify({
membership_type: membershipType,
});
+12 -10
View File
@@ -15,6 +15,7 @@ import {
import RenderHtml from 'react-native-render-html';
import theme from 'src/theme';
import type { EventType } from 'src/types/data';
import type { EventDetailProps } from 'src/types/navigation';
moment.locale('nb');
@@ -27,11 +28,11 @@ const FORMAT_DAY = 'DD.';
const FORMAT_LOCAL_DAY = 'dddd';
const FORMAT_YEAR = 'YYYY';
const formatRelative = (time) => moment(time).fromNow();
const formatRelative = (time: string) => moment(time).fromNow();
const format = (time, formatString = FORMAT_TIME) => moment(time).format(formatString);
const format = (time: string, formatString = FORMAT_TIME) => moment(time).format(formatString);
const formatTicketText = (event) => {
const formatTicketText = (event: EventType) => {
/* FIXME: This should be handled by API */
let regularPrice = '';
let memberPrice = '';
@@ -61,7 +62,7 @@ const formatTicketText = (event) => {
return text;
};
const onFacebookUrlPress = (link) => {
const onFacebookUrlPress = (link: string) => {
const fbEventPattern = /https?:\/\/www\.facebook\.com\/events\/(\d+)\/?/i;
const matches = fbEventPattern.exec(link);
let iosURL = link;
@@ -82,15 +83,16 @@ const onFacebookUrlPress = (link) => {
.catch((err) => console.error('An error occurred', err));
};
const onLocationPress = (venue) => {
const onLocationPress = (venue: string) => {
if (venue !== 'Annetsteds') {
Linking.openURL(`https://www.google.com/maps/dir/?api=1&destination=${CHATEAU_NEUF_ADDRESS}`);
}
};
const VenueLocation = ({ venue }) => (venue !== 'Annetsteds' ? CHATEAU_NEUF_ADDRESS : 'Ukjent adresse');
const VenueLocation = ({ venue }: Pick<EventType, 'venue'>) =>
venue !== 'Annetsteds' ? CHATEAU_NEUF_ADDRESS : 'Ukjent adresse';
const PriceAndTicket = ({ event }) => {
const PriceAndTicket = ({ event }: { event: EventType }) => {
const text = formatTicketText(event);
if (text === '') {
@@ -124,7 +126,7 @@ const PriceAndTicket = ({ event }) => {
);
};
const FaceButton = ({ event }) => {
const FaceButton = ({ event }: { event: EventType }) => {
if (!event.facebook_url) {
return null;
}
@@ -141,14 +143,14 @@ const FaceButton = ({ event }) => {
);
};
const Year = ({ startTime }) => {
const Year = ({ startTime }: { startTime: string }) => {
if (moment().year() === moment(startTime).year()) {
return null;
}
return <Text style={styles.year}>{format(startTime, FORMAT_YEAR)}</Text>;
};
const formatTimeFull = (event) => {
const formatTimeFull = (event: EventType) => {
const endTime = event.end_time ? ` - ${format(event.end_time, FORMAT_TIME)}` : '';
const startTime = `${format(event.start_time, FORMAT_LOCAL_DAY)} kl. ${format(event.start_time, FORMAT_TIME)}`;
return `${startTime}${endTime}`;
+1 -1
View File
@@ -41,7 +41,7 @@ const Login = () => {
small
light
onPress={() => {
Linking.openURL(process.env.EXPO_PUBLIC_DUSKEN_FORGOT_PASSWORD_URL);
Linking.openURL(process.env.EXPO_PUBLIC_DUSKEN_FORGOT_PASSWORD_URL || '');
}}
style={styles.forgotPasswordButton}
>
+2 -4
View File
@@ -8,7 +8,7 @@ import Proof from './Proof';
import useMembership from './useMembership';
const Membership = () => {
const { isFetchingUserData, onRegisterPress, onLoginPress, onLogoutPress, user, isAuthenticated, fetchUser } =
const { isFetchingUserData, onRegisterPress, onLoginPress, onLogoutPress, isAuthenticated, fetchUser } =
useMembership();
if (isFetchingUserData) {
@@ -20,9 +20,7 @@ const Membership = () => {
}
if (isAuthenticated) {
return (
<Proof onLogoutPress={onLogoutPress} user={user} fetchUser={fetchUser} isFetchingUserData={isFetchingUserData} />
);
return <Proof onLogoutPress={onLogoutPress} fetchUser={fetchUser} isFetchingUserData={isFetchingUserData} />;
}
return <MembershipInfo onRegister={onRegisterPress} onLogin={onLoginPress} />;
+5 -1
View File
@@ -4,7 +4,11 @@ import { Button } from 'react-native-ui-lib';
import theme from 'src/theme';
const MembershipInfo = ({ onRegister, onLogin }) => (
interface MembershipInfoProps {
onRegister: () => void;
onLogin: () => void;
}
const MembershipInfo = ({ onRegister, onLogin }: MembershipInfoProps) => (
<ScrollView>
<View style={styles.card}>
<Text style={styles.headerTitle}>Hei 👋</Text>
+48 -13
View File
@@ -9,18 +9,20 @@ import {
RefreshControl,
ActivityIndicator,
Dimensions,
GestureResponderEvent,
} from 'react-native';
import Confetti from 'react-native-confetti';
import { Button, Incubator } from 'react-native-ui-lib';
import theme from 'src/theme';
import type { UserType } from 'src/types/data';
import useProof from './useProof';
import logo from '../../../assets/DNS-logo-svart.png';
const { Toast } = Incubator;
const MembershipValidTo = ({ user }) => {
const MembershipValidTo = ({ user }: { user: UserType }) => {
if (!user.last_membership) {
return null;
}
@@ -38,7 +40,13 @@ const MembershipValidTo = ({ user }) => {
);
};
const MembershipStatus = ({ user, onMembershipPress }) => {
const MembershipStatus = ({
user,
onMembershipPress,
}: {
user: UserType;
onMembershipPress: (event: GestureResponderEvent) => void | undefined;
}) => {
if (!user.is_member) {
if (!user.last_membership) {
return null;
@@ -62,7 +70,21 @@ const MembershipStatus = ({ user, onMembershipPress }) => {
);
};
const PurchaseButton = ({ user, paymentLoading, isLoadingMembershipType, onChargePress, membershipPrice }) => {
interface PurchaseButtonProps {
user: UserType;
paymentLoading: boolean;
isLoadingMembershipType: boolean;
membershipPrice: number;
onChargePress: () => void;
}
const PurchaseButton = ({
user,
paymentLoading,
isLoadingMembershipType,
onChargePress,
membershipPrice,
}: PurchaseButtonProps) => {
if (user.is_member) {
return null;
}
@@ -85,7 +107,8 @@ const PurchaseButton = ({ user, paymentLoading, isLoadingMembershipType, onCharg
);
};
const UserConfetti = ({ user, confettiRef }) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const UserConfetti = ({ user, confettiRef }: { user: UserType; confettiRef: any }) => {
if (!user.is_member) {
return null;
}
@@ -93,7 +116,7 @@ const UserConfetti = ({ user, confettiRef }) => {
// Confetti!!!!
return <Confetti ref={confettiRef} confettiCount={Number.MAX_SAFE_INTEGER} />;
};
const Logo = ({ user }) => {
const Logo = ({ user }: { user: UserType }) => {
if (!user.is_member) {
return null;
}
@@ -117,7 +140,7 @@ const ChargeError = ({ chargeError }) => {
);
};
const MembershipName = ({ user }) => {
const MembershipName = ({ user }: { user: UserType }) => {
let name = `${user.first_name} ${user.last_name}`;
if (name === ' ') {
name = user.email;
@@ -129,14 +152,26 @@ const MembershipName = ({ user }) => {
);
};
const Proof = ({ onLogoutPress, user, fetchUser, isFetchingUserData }) => {
interface ProofProps {
onLogoutPress: () => void;
fetchUser: () => void;
isFetchingUserData: boolean;
}
const Proof = ({ onLogoutPress, fetchUser, isFetchingUserData }: ProofProps) => {
const CONFETTI_TIMEOUT = 15000;
const confettiRef = useRef(null);
const { chargeError, paymentLoading, isLoadingMembershipType, onChargePress, membershipPrice, toast, setToast } =
useProof({
user,
fetchUser,
});
const confettiRef = useRef<Confetti>(null);
const {
chargeError,
paymentLoading,
isLoadingMembershipType,
onChargePress,
membershipPrice,
toast,
setToast,
user,
} = useProof({
fetchUser,
});
useEffect(() => {
const currentRef = confettiRef.current;
-2
View File
@@ -11,7 +11,6 @@ const useMembership = () => {
const isAuthenticated = useAppSelector((store) => store.isAuthenticated);
const isFetchingUserData = useAppSelector((store) => store.isFetchingUserData);
const lastOrder = useAppSelector((store) => store.lastOrder);
const user = useAppSelector((store) => store.user);
const userToken = useAppSelector((store) => store.userToken);
const prevUserToken = usePrevious(userToken);
const prevLastOrder = usePrevious(lastOrder);
@@ -39,7 +38,6 @@ const useMembership = () => {
isAuthenticated,
isFetchingUserData,
userToken,
user,
lastOrder,
onLogoutPress,
onRegisterPress,
+20 -17
View File
@@ -2,10 +2,9 @@ import { gql, useQuery } from '@apollo/client';
import { useStripe } from '@stripe/stripe-react-native';
import Constants from 'expo-constants';
import { useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import { requestStripePaymentSheet } from 'src/api';
import { RootState } from 'src/reduxStore';
import { useAppSelector } from 'src/hooks';
const PROOF_QUERY = gql`
{
@@ -16,22 +15,21 @@ const PROOF_QUERY = gql`
}
`;
function getPrice(membershipTypes) {
if (!membershipTypes) {
return 0;
}
return membershipTypes[0].price / 100;
interface ToastProps {
message: string;
visible: boolean;
preset: 'failure' | 'success';
}
const useProof = ({ user, fetchUser }) => {
const userToken = useSelector((state: RootState) => state.userToken);
const chargeError = useSelector((state: RootState) => state.chargeError);
const useProof = ({ fetchUser }) => {
const user = useAppSelector((state) => state.user);
const userToken = useAppSelector((state) => state.userToken);
const chargeError = useAppSelector((state) => state.chargeError);
const { data, loading } = useQuery(PROOF_QUERY);
const { initPaymentSheet, presentPaymentSheet } = useStripe();
const [paymentLoading, setPaymentLoading] = useState(false);
const membershipTypeSlug = data?.membershipTypes?.[0]?.slug ?? 'standard';
const [toast, setToast] = useState(null);
const [toast, setToast] = useState<ToastProps | null>(null);
const openPaymentSheet = async () => {
try {
@@ -48,7 +46,10 @@ const useProof = ({ user, fetchUser }) => {
const initializePaymentSheet = async () => {
setPaymentLoading(true);
const { paymentIntent, ephemeralKey, customer } = await requestStripePaymentSheet(userToken, membershipTypeSlug);
const { paymentIntent, ephemeralKey, customer } = await requestStripePaymentSheet(
userToken as string,
membershipTypeSlug,
);
const { error } = await initPaymentSheet({
merchantDisplayName: 'Det Norske Studentersamfund',
@@ -56,13 +57,13 @@ const useProof = ({ user, fetchUser }) => {
customerEphemeralKeySecret: ephemeralKey,
paymentIntentClientSecret: paymentIntent,
defaultBillingDetails: {
email: user.email,
name: `${user.first_name} ${user.last_name}`,
email: user?.email,
name: `${user?.first_name} ${user?.last_name}`,
address: {
country: 'NO',
},
},
returnURL: `${Constants.expoConfig.scheme}://stripe-redirect`,
returnURL: `${Constants.expoConfig?.scheme}://stripe-redirect`,
});
if (!error) {
setPaymentLoading(true);
@@ -78,14 +79,16 @@ const useProof = ({ user, fetchUser }) => {
}
}, [fetchUser, user?.last_membership]);
const membershipPrice = data?.membershipTypes?.[0]?.price;
return {
chargeError: chargeError || '',
isLoadingMembershipType: loading,
membershipPrice: getPrice(data?.membershipTypes),
membershipPrice: membershipPrice ? membershipPrice / 100 : 0,
onChargePress: openPaymentSheet,
paymentLoading,
toast,
setToast,
user,
};
};
+18 -2
View File
@@ -13,10 +13,26 @@ import {
MEMBERSHIP_CHARGE_FAILURE,
MEMBERSHIP_CHARGE_SUCCESS,
} from './actions';
import type { UserType } from './types/data';
import { snakeToCamelCase } from './utils';
// FIXME: type this
const initialState = {
interface RootState {
isAuthenticated: boolean;
isLoggingIn: boolean;
isFetchingUserData: boolean;
isRegisteringUser: boolean;
isChargingMembership: boolean;
userToken: string | null;
user: UserType | null;
// FIXME: Type rest
loginError: unknown | null;
userError: unknown | null;
registerError: unknown | null;
chargeError: unknown | null;
lastOrder: unknown | null;
}
const initialState: RootState = {
isAuthenticated: false,
isLoggingIn: false,
isFetchingUserData: false,
+19
View File
@@ -1,5 +1,6 @@
export interface EventType {
start_time: string;
end_time: string;
content: {
rendered: string;
};
@@ -10,4 +11,22 @@ export interface EventType {
thumbnail: {
medium_large: string;
};
price_regular: string;
price_member: string;
ticket_url: string;
facebook_url: string;
}
export interface MembershipType {
membership_type: string;
end_date: string;
}
export interface UserType {
first_name: string;
last_name: string;
email: string;
last_membership: MembershipType | null;
is_volunteer: boolean;
is_member: boolean;
}
+1 -30
View File
@@ -1,6 +1,4 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
export function snakeToCamelCase(val) {
export function snakeToCamelCase(val: string) {
return val.replace(/_([a-z])/g, (g) => g[1].toUpperCase());
}
@@ -16,30 +14,3 @@ export function fetchWithTimeout(url: string, options?: RequestInit, timeout = 1
}),
]);
}
export async function migrateReduxPersistFourToFive(registerUserSuccess) {
const authedKey = 'reduxPersist:isAuthenticated';
const userKey = 'reduxPersist:user';
const userTokenKey = 'reduxPersist:userToken';
const v4Keys = [authedKey, userKey, userTokenKey];
const asyncStorageKeys = await AsyncStorage.getAllKeys();
if (v4Keys.some((key) => !asyncStorageKeys.includes(key))) {
return;
}
const isAuthenticated = await AsyncStorage.getItem(authedKey);
console.log(authedKey, isAuthenticated);
if (isAuthenticated !== 'true') {
await AsyncStorage.multiRemove(v4Keys);
return;
}
const userToken = await AsyncStorage.getItem(userTokenKey);
console.log(userTokenKey, userToken);
const user = await AsyncStorage.getItem(userKey);
console.log(userKey, user, JSON.parse(user));
registerUserSuccess({ auth_token: JSON.parse(userToken), ...JSON.parse(user) });
await AsyncStorage.multiRemove(v4Keys);
console.log('successfully migrated to new redux-persist');
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"compilerOptions": {
"baseUrl": "./"
"baseUrl": "./",
},
"extends": "expo/tsconfig.base"
}