From 85b0cd5423c952a7a7b3b810bee0b02be0d35e53 Mon Sep 17 00:00:00 2001 From: William Huster Date: Thu, 29 Aug 2024 13:36:18 -0400 Subject: [PATCH 1/2] Improve documentation for Playwright tests. (#348) - Changes some missed references to CYPRESS to PLAYWRIGHT in .env.example, and corrects the env variable name for PLAYWRIGHT_TEST_BASE_URL. - Adds additional installation steps to README.md that are necessary to run Playwright locally --- {{cookiecutter.project_slug}}/.env.example | 4 ++-- {{cookiecutter.project_slug}}/README.md | 8 +++++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/{{cookiecutter.project_slug}}/.env.example b/{{cookiecutter.project_slug}}/.env.example index 78bef4911..171b47ec8 100644 --- a/{{cookiecutter.project_slug}}/.env.example +++ b/{{cookiecutter.project_slug}}/.env.example @@ -151,5 +151,5 @@ DEFAULT_FROM_EMAIL='{{ cookiecutter.project_name }} Date: Fri, 30 Aug 2024 12:09:37 -0700 Subject: [PATCH 2/2] React Native - Add Settings Stack (#347) --- cookiecutter.json | 4 +- .../clients/mobile/react-native/App.tsx | 2 +- .../src/components/contact-email-button.tsx | 23 ++ .../react-native/src/components/container.tsx | 22 ++ .../react-native/src/screens/dashboard.tsx | 17 +- .../mobile/react-native/src/screens/routes.ts | 4 +- .../src/screens/settings/contact-us.tsx | 50 +++++ .../src/screens/settings/edit-profile.tsx | 207 ++++++++++++++++++ .../src/screens/settings/index.tsx | 3 + .../src/screens/settings/main-settings.tsx | 206 +++++++++++++++++ .../react-native/src/services/user/hooks.ts | 22 +- .../react-native/src/services/user/index.ts | 3 +- .../react-native/src/services/user/models.ts | 8 + .../react-native/src/services/user/queries.ts | 19 ++ .../react-native/src/utils/constants.ts | 1 + .../core/views.py | 1 + 16 files changed, 572 insertions(+), 20 deletions(-) create mode 100644 {{cookiecutter.project_slug}}/clients/mobile/react-native/src/components/contact-email-button.tsx create mode 100644 {{cookiecutter.project_slug}}/clients/mobile/react-native/src/components/container.tsx create mode 100644 {{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/settings/contact-us.tsx create mode 100644 {{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/settings/edit-profile.tsx create mode 100644 {{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/settings/index.tsx create mode 100644 {{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/settings/main-settings.tsx create mode 100644 {{cookiecutter.project_slug}}/clients/mobile/react-native/src/services/user/queries.ts diff --git a/cookiecutter.json b/cookiecutter.json index 795d7eaf9..ac51bf314 100644 --- a/cookiecutter.json +++ b/cookiecutter.json @@ -14,7 +14,9 @@ "clients/web/react/src/pages/app-or-auth.tsx", "clients/web/react/src/pages/index.ts", "clients/web/react/src/pages/layout.tsx", - "*/swagger-ui.html" + "*/swagger-ui.html", + "clients/mobile/react-native/src/screens/settings/edit-profile.tsx", + "clients/mobile/react-native/src/screens/settings/main-settings.tsx" ], "mail_service": [ "Mailgun", diff --git a/{{cookiecutter.project_slug}}/clients/mobile/react-native/App.tsx b/{{cookiecutter.project_slug}}/clients/mobile/react-native/App.tsx index 75754d26e..872edbc8a 100644 --- a/{{cookiecutter.project_slug}}/clients/mobile/react-native/App.tsx +++ b/{{cookiecutter.project_slug}}/clients/mobile/react-native/App.tsx @@ -65,7 +65,7 @@ export default Sentry.wrap((): JSX.Element => { - + diff --git a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/components/contact-email-button.tsx b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/components/contact-email-button.tsx new file mode 100644 index 000000000..56d86e952 --- /dev/null +++ b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/components/contact-email-button.tsx @@ -0,0 +1,23 @@ +import { Linking, Text } from 'react-native' +import { Bounceable } from 'rn-bounceable' +import { useConstants } from '@utils/constants' + +export const ContactEmailButton = () => { + const { supportEmail } = useConstants() + const handleSendMail = async () => { + const mailtoUrl = `mailto:${supportEmail}` + const canOpen = await Linking.canOpenURL(mailtoUrl) + if (canOpen) { + Linking.openURL(mailtoUrl) + } else { + console.error('could not open this url: ', mailtoUrl) + } + } + return ( + + + {supportEmail} + + + ) +} diff --git a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/components/container.tsx b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/components/container.tsx new file mode 100644 index 000000000..0a6dccb00 --- /dev/null +++ b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/components/container.tsx @@ -0,0 +1,22 @@ +import { FC, ReactNode } from 'react' +import { View } from 'react-native' +import { MultiPlatformSafeAreaView } from './multi-platform-safe-area-view' + +export const Container: FC<{ + children: ReactNode + containerClassName?: string + innerContainerClassName?: string + hasHorizontalPadding?: boolean +}> = ({ children, containerClassName, innerContainerClassName, hasHorizontalPadding = true }) => { + return ( + + + {children} + + + ) +} diff --git a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/dashboard.tsx b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/dashboard.tsx index 40daa676f..9148dfce7 100644 --- a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/dashboard.tsx +++ b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/dashboard.tsx @@ -7,6 +7,8 @@ import { useAtomValue } from 'jotai' import React from 'react' import { Text, View } from 'react-native' import { SheetManager } from 'react-native-actions-sheet' +import Ionicons from '@expo/vector-icons/Ionicons' +import { BounceableWind } from '@components/styled' export const DashboardScreen = () => { const navio = useAtomValue(navioAtom) @@ -22,8 +24,19 @@ export const DashboardScreen = () => { return ( - - Welcome to the Dashboard + + + navio?.stacks.push('SettingsStack')} + > + Settings + + + + + Welcome to the Dashboard + diff --git a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/routes.ts b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/routes.ts index 8ca71e2af..bd6a8eedc 100644 --- a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/routes.ts +++ b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/routes.ts @@ -7,6 +7,7 @@ import { Main } from '@screens/main' import { Auth } from '@screens/auth/auth' import { DashboardScreen } from '@screens/dashboard' import { ComponentsPreview } from '@screens/ComponentsPreview' +import { ContactUs, EditProfile, Settings } from '@screens/settings' // Default options - forcing a mobile trigger export const screenDefaultOptions = (): NativeStackNavigationOptions => ({ @@ -29,10 +30,11 @@ export const tabDefaultOptions = (): BottomTabNavigationOptions => ({ }) // NAVIO export const navio = Navio.build({ - screens: { Auth, Login, SignUp, Main, DashboardScreen, ComponentsPreview }, + screens: { Auth, Login, SignUp, Main, DashboardScreen, ComponentsPreview, Settings, ContactUs, EditProfile }, stacks: { AuthStack: ['Auth'], MainStack: ['DashboardScreen'], + SettingsStack: ['Settings', 'ContactUs', 'EditProfile'], /** * Set me as the root to see the components preview */ diff --git a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/settings/contact-us.tsx b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/settings/contact-us.tsx new file mode 100644 index 000000000..2bbef7cbf --- /dev/null +++ b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/settings/contact-us.tsx @@ -0,0 +1,50 @@ +import { Text, View } from 'react-native' +import { BButton } from '@components/Button' +import { Container } from '@components/container' +import { getNavio } from '..' +import { ContactEmailButton } from '@components/contact-email-button' +import { Ionicons } from '@expo/vector-icons' +import colors from '@utils/colors' +import { BounceableWind } from '@components/styled' + +export const ContactUs = () => { + const navio = getNavio() + + return ( + + + { + navio.goBack() + }} + contentContainerClassName="absolute left-0 top-0" + > + + + + Contact Us + + + + + + Needing help? + + + + Reach out to us at + + + + + { + navio.goBack() + }} + /> + + + ) +} diff --git a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/settings/edit-profile.tsx b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/settings/edit-profile.tsx new file mode 100644 index 000000000..0329e9ecf --- /dev/null +++ b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/settings/edit-profile.tsx @@ -0,0 +1,207 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useState } from 'react' +import { ActivityIndicator, Alert, ScrollView, TextInput, Text, View } from 'react-native' +import { Bounceable } from 'rn-bounceable' +import { Container } from '@components/container' +import { UserShape, fullNameZod, useLogout, useUser, userApi } from '@services/user' +import { userQueries } from '@services/user/queries' +import { getNavio } from '..' +import { ErrorMessage } from '@components/errors' +import { Ionicons } from '@expo/vector-icons' +import colors from '@utils/colors' +import { BButton } from '@components/Button' +import { isAxiosError } from 'axios' +import { useAuth } from '@stores/auth' + +const Separator = () => { + return ( + + + + ) +} + +export const EditProfile = () => { + const navio = getNavio() + const onCancel = () => { + navio.goBack() + } + const { data: user } = useUser() + const { userId } = useAuth() + const [fullName, setFullName] = useState(user?.fullName ?? '') + const [errors, setErrors] = useState() + + const handleFullNameChange = (name: string) => { + setFullName(name) + } + const unsavedChanges = user && user.fullName !== fullName + + const parsedName = fullNameZod.safeParse(fullName) + const isValid = parsedName.success + const qClient = useQueryClient() + + const { mutate: save, isPending: isSaving } = useMutation({ + mutationFn: userApi.update, + onMutate: async () => { + const userSnapshot = qClient.getQueryData( + userQueries.retrieve(userId).queryKey, + )?.fullName + await qClient.cancelQueries({ queryKey: userQueries.retrieve(userId).queryKey }) + qClient.setQueryData(userQueries.retrieve(userId).queryKey, (input?: UserShape) => { + return input ? { ...input } : undefined + }) + return { userSnapshot } + }, + onSuccess: () => { + qClient.invalidateQueries({ queryKey: userQueries.all() }) + }, + onError: (e, _, context) => { + //rollback update + qClient.setQueryData(userQueries.retrieve(userId).queryKey, (input?: UserShape) => { + return input + ? { ...input, fullName: context?.userSnapshot ?? user?.fullName ?? '' } + : undefined + }) + + if (isAxiosError(e)) { + const { data } = e?.response ?? {} + if (data) { + const isArrayOfStrings = Array.isArray(data) && data.length && typeof data[0] === 'string' + const isObjectOfErrors = Object.keys(data).every((key) => Array.isArray(data[key])) + setErrors( + (isArrayOfStrings + ? data + : isObjectOfErrors + ? Object.keys(data).map((key) => data[key]) + : ['Something went wrong']) as string[], + ) + } + } + }, + }) + + const { mutate: logout, isPending: isLoggingOut } = useLogout() + + const { mutate: deleteUser, isPending: isDeleting } = useMutation({ + mutationFn: userApi.remove, + onSuccess: () => { + logout() + navio.stacks.setRoot('AuthStack') + }, + onError: () => { + Alert.alert('Error', "Couldn't delete your account. Please try again later.", [ + { + text: 'Ok', + }, + ]) + }, + }) + + const handleSave = () => { + if (!user) return + const [firstName, lastName] = fullName.split(' ') + save({ id: user.id, firstName, lastName }) + } + + const showWarningAlert = () => { + Alert.alert( + 'WARNING', + 'Deleting your account is permanent and cannot be undone. If you would like to use this app again, you will need to create a new account.', + [ + { + text: 'Cancel', + style: 'cancel', + }, + { + text: 'Delete', + onPress: () => { + if (!user) return + deleteUser(user?.id) + }, + }, + ], + ) + } + + if (!user) return <> //never + + return ( + + + + + + Edit Profile + + {isSaving ? ( + + ) : ( + + Save + + )} + + + + + + + Full Name + + + + + + + + + {fullName && !isValid ? ( + + + {parsedName.error.issues.map((i) => i.message).join(', ')} + + + ) : null} + + + + Email + + + + {user.email} + + + {errors?.map((error, idx) => ( + + {error} + + ))} + + + + + + + ) +} diff --git a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/settings/index.tsx b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/settings/index.tsx new file mode 100644 index 000000000..f1d6c0718 --- /dev/null +++ b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/settings/index.tsx @@ -0,0 +1,3 @@ +export { Settings } from './main-settings' +export { ContactUs } from './contact-us' +export { EditProfile } from './edit-profile' diff --git a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/settings/main-settings.tsx b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/settings/main-settings.tsx new file mode 100644 index 000000000..ee5ef36b9 --- /dev/null +++ b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/screens/settings/main-settings.tsx @@ -0,0 +1,206 @@ +import { BButton } from '@components/Button' +import { MaterialIcons, Ionicons, AntDesign } from '@expo/vector-icons' +import { useLogout, useUser } from '@services/user' +import * as Application from 'expo-application' +import * as Updates from 'expo-updates' +import { openBrowserAsync } from 'expo-web-browser' +import React from 'react' +import { Alert, ScrollView, Text, View } from 'react-native' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { Bounceable } from 'rn-bounceable' +import { AppScreens, getNavio } from '..' +import colors from '@utils/colors' +import { Container } from '@components/container' + +import { BounceableWind } from '@components/styled' + +type SectionChild = { + title: string + icon: JSX.Element + args: + | { + screenName: AppScreens + screenProps?: Record + } + | { link: string } + } + + type Section = { + name: string + children: SectionChild[] + } + + const sections: Section[] = [ + { + name: 'About', + children: [ + { + title: 'Terms of Service', + icon: , + args: { + link: 'https://www.thinknimble.com/privacy-policy', + }, + }, + { + title: 'Privacy Policy', + icon: , + args: { + link: 'https://www.thinknimble.com/privacy-policy', + }, + }, + { + title: 'Contact us', + icon: , + args: { + screenName: 'ContactUs', + }, + }, + ], + }, + ] + + const UserCard = () => { + const { data: user } = useUser() + const navio = getNavio() + + const handlePress = () => { + navio.push('EditProfile') + } + if (!user) return <> + + return ( + + + + + + + + + {user.fullName} + + {user.email} + + + + + + + + + + ) + } + + const SectionList = () => { + const navio = getNavio() + return ( + + {sections.map((s, sIdx) => { + return ( + + + + {s.name} + + + {s.children.map((sc, scIdx) => { + return ( + { + if ('screenName' in sc.args) { + navio.push( + sc.args.screenName, + 'screenProps' in sc.args ? sc.args.screenProps : undefined, + ) + return + } else if ('link' in sc.args) { + openBrowserAsync(sc.args.link) + } + }} + key={scIdx} + > + + + + {sc.icon} + {sc.title} + + + + + + + + ) + })} + + ) + })} + + ) + } + + +export const Settings = () => { + const navio = getNavio() + const { bottom } = useSafeAreaInsets() + const { mutate: logout, isPending: isLoggingOut } = useLogout() + + const handleLogout = () => { + logout(undefined, { + onSettled: () => { + navio?.setRoot('stacks', 'AuthStack') + }, + }) + } + + const showWarningAlert = () => { + Alert.alert('Log out', 'Are you sure you want to log out?', [ + { + text: 'Cancel', + style: 'cancel', + }, + {text: 'Log out', onPress: handleLogout}, + ]) + } + + return ( + + + { + navio.goBack() + }} + contentContainerClassName="absolute left-0 top-0" + > + + + + Settings + + + + + + + + + Version released {Application.nativeApplicationVersion} ( + {Application.nativeBuildVersion}) - {Updates.channel ? Updates.channel : 'Dev'} + + + + + ) + } \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/services/user/hooks.ts b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/services/user/hooks.ts index e215452c5..dc4b2a4e6 100644 --- a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/services/user/hooks.ts +++ b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/services/user/hooks.ts @@ -6,28 +6,22 @@ import { useEffect } from 'react' import { useQuery, useMutation } from '@tanstack/react-query' import { useAuth } from '@stores/auth' -import { userApi } from './api' import { queryClient } from '@utils/query-client' +import { userQueries } from './queries' +import { userApi } from './api' export const useUser = () => { const userId = useAuth.use.userId() const { writeUserInStorage } = useAuth.use.actions() - const data = useQuery({ - queryKey: ['user', userId], - queryFn: async () => { - const user = await userApi.retrieve(userId) - return user - }, - enabled: Boolean(userId), - }) + const query = useQuery(userQueries.retrieve(userId)) useEffect(() => { - if (data.isSuccess && data.data) { - writeUserInStorage(data.data) + if (query.isSuccess && query.data) { + writeUserInStorage(query.data) } - }, [data.data, data.isSuccess, writeUserInStorage]) + }, [query.data, query.isSuccess, writeUserInStorage]) - return data + return query } @@ -39,7 +33,7 @@ export const useLogout = () => { mutationFn: userApi.csc.logout, onSettled: () => { useAuth.getState().actions.clearAuth() - queryClient.invalidateQueries({ queryKey: ['user'] }) + queryClient.invalidateQueries({ queryKey: userQueries.all() }) }, }) } \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/services/user/index.ts b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/services/user/index.ts index 2e69e848c..43a250bac 100644 --- a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/services/user/index.ts +++ b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/services/user/index.ts @@ -1,4 +1,5 @@ export * from './forms' export * from './api' export * from './models' -export * from './hooks' \ No newline at end of file +export * from './hooks' +export * from './queries' \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/services/user/models.ts b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/services/user/models.ts index ba4c4ae5c..c59cc3eb2 100644 --- a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/services/user/models.ts +++ b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/services/user/models.ts @@ -14,6 +14,7 @@ export const userShape = { email: z.string().email(), firstName: z.string(), lastName: z.string(), + fullName: z.string(), //TODO:add back `readonly` https://github.com/thinknimble/tn-models-fp/issues/161 token: z.string().nullable(), } @@ -38,3 +39,10 @@ export const loginShape = { } export type LoginShape = GetInferredFromRaw + +export const fullNameZod = z.string().refine( + (value) => { + return value.split(' ').filter(Boolean).length >= 2 + }, + { message: 'Please provide a full name (first and last name)' }, +) diff --git a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/services/user/queries.ts b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/services/user/queries.ts new file mode 100644 index 000000000..9adeb32c8 --- /dev/null +++ b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/services/user/queries.ts @@ -0,0 +1,19 @@ + +import { queryOptions } from '@tanstack/react-query' +import { userApi } from './api' + +/** + * @link https://tkdodo.eu/blog/the-query-options-api?ck_subscriber_id=1819338276 + * Create query factories for a more type safe solution of the queries across the app. This way whichever invalidation that has to happen in the resource can be done with `queryClient.invalidateQueries(userQueries.all())` or any other of the query factory's functions. + * In this case we do have a very simple example for the user query but depending on the resource we may want to add more query factories + */ + +export const userQueries = { + all: () => ['users'], + retrieve: (id: string) => + queryOptions({ + queryKey: [...userQueries.all(), id], + queryFn: () => userApi.retrieve(id), + enabled: Boolean(id), + }), +} diff --git a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/utils/constants.ts b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/utils/constants.ts index f4034be5b..15c002fa8 100644 --- a/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/utils/constants.ts +++ b/{{cookiecutter.project_slug}}/clients/mobile/react-native/src/utils/constants.ts @@ -9,5 +9,6 @@ export const useConstants = () => { github: 'https://github.com/kanzitelli/expo-starter', website: 'https://github.com/kanzitelli/expo-starter', }, + supportEmail: 'hello@thinknimble.com', }; }; diff --git a/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/views.py b/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/views.py index 0a702dab9..42b4ce05b 100755 --- a/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/views.py +++ b/{{cookiecutter.project_slug}}/server/{{cookiecutter.project_slug}}/core/views.py @@ -55,6 +55,7 @@ class UserViewSet( mixins.RetrieveModelMixin, mixins.ListModelMixin, mixins.UpdateModelMixin, + mixins.DestroyModelMixin, ): queryset = User.objects.all() serializer_class = UserSerializer