diff --git a/docs/en/tutorials/mobile/react-native/index.md b/docs/en/tutorials/mobile/react-native/index.md index 3bfeb245c5..bddd3a99a0 100644 --- a/docs/en/tutorials/mobile/react-native/index.md +++ b/docs/en/tutorials/mobile/react-native/index.md @@ -1,20 +1,21 @@ ```json //[doc-seo] { - "Description": "Learn how to develop a mobile application using React Native with ABP Framework, focusing on UI for the Acme.BookStore app." + "Description": "Learn how to develop a mobile application using React Native with the ABP Framework. Build the Acme.BookStore mobile UI on top of the modernized ABP React Native template (NativeWind v4 + Bottom Tab navigation)." } ``` # Mobile Application Development Tutorial - React Native -React Native mobile option is *available for* ***Team*** *or higher licenses*. Therefore, if you don't have a commercial license, it's suggested to follow the article by downloading the source code of the sample application as described in the next chapter. +The React Native mobile option is *available for* ***Team*** *or higher licenses*. If you don't have a commercial license, follow this article by downloading the source code of the sample application linked below. ## About This Tutorial > You must have an [ABP Team or a higher license](https://abp.io/pricing) to be able to create a mobile application. -- This tutorial assumes that you have completed the [Web Application Development tutorial](../../book-store/part-01.md) and built an ABP based application named `Acme.BookStore` with [React Native](../../../framework/ui/react-native) as the mobile option. Therefore, if you haven't completed the [Web Application Development tutorial](../../book-store/part-01.md), you either need to complete it or download the source code from down below and follow this tutorial. -- In this tutorial, we will only focus on the UI side of the `Acme.BookStore` application and will implement the CRUD operations. +- This tutorial assumes you have completed the [Web Application Development tutorial](../../book-store/part-01.md) and built an ABP based application named `Acme.BookStore` with [React Native](../../../framework/ui/react-native) as the mobile option. If you haven't completed it, you can either complete it first or download the source code below and follow this tutorial. +- This tutorial only focuses on the **React Native UI side** of the `Acme.BookStore` application. It implements the CRUD operations for `Books` and `Authors`, plus the relation between them. The backend (entities, application services, permissions, seeder) is already in place in the downloadable sample. +- The mobile template was modernized in 2026: it now uses **NativeWind v4** (Tailwind CSS for React Native) for styling, **Bottom Tab navigation** by default, and the **Redux Toolkit** store with hook-based access (`useSelector` / `useDispatch`). The `connectToRedux` HOC, the `DrawerNavigator`, and the legacy `DataList`/`AbpSelect` components from earlier versions no longer ship with the template — this tutorial walks through building the new equivalents. - Before starting, please make sure that the [React Native Development Environment](../../../framework/ui/react-native/index.md) is ready on your machine. ## Download the Source Code @@ -25,1911 +26,1198 @@ You can use the following link to download the source code of the application de > If you encounter the "filename too long" or "unzip" error on Windows, please see [this guide](../../../kb/windows-path-too-long-fix.md). -## The Book List Page - -There is no dynamic proxy generation for the react native application, that is why we need to create the BookAPI proxy manually under the `./src/api` folder. +The downloaded sample contains: -```ts -//./src/api/BookAPI.ts -import api from './API'; +- `src/` — ABP backend (`Acme.BookStore.*` projects). It already exposes `BookAppService` and `AuthorAppService` with the CRUD endpoints we will consume. +- `react-native/` — the React Native client. The auth, profile and settings flows ship out of the box. Throughout this tutorial we will add the `BookStore` feature to it. -export const getList = () => api.get('/api/app/book').then(({ data }) => data); +## Backend Setup (Quick Reference) -export const get = id => api.get(`/api/app/book/${id}`).then(({ data }) => data); +The backend ships ready-to-run. The relevant pieces consumed from React Native are: -export const create = input => api.post('/api/app/book', input).then(({ data }) => data); +- **Endpoints** + - `GET /api/app/book` — paged list (returns `items` with `id`, `name`, `type`, `publishDate`, `price`, `authorName`) + - `GET /api/app/book/{id}` — single book + - `POST /api/app/book` — create + - `PUT /api/app/book/{id}` — update + - `DELETE /api/app/book/{id}` — delete + - `GET /api/app/book/author-lookup` — `{ items: [{ id, name }] }` for the author dropdown + - `GET /api/app/author` — paged list (`items: [{ id, name, birthDate, shortBio }]`) + - `GET /api/app/author/{id}`, `POST /api/app/author`, `PUT /api/app/author/{id}`, `DELETE /api/app/author/{id}` +- **Permissions** (use these names from the React Native side to gate UI): + - `BookStore.Books`, `BookStore.Books.Create`, `BookStore.Books.Edit`, `BookStore.Books.Delete` + - `BookStore.Authors`, `BookStore.Authors.Create`, `BookStore.Authors.Edit`, `BookStore.Authors.Delete` -export const update = (input, id) => api.put(`/api/app/book/${id}`, input).then(({ data }) => data); - -export const remove = id => api.delete(`/api/app/book/${id}`).then(({ data }) => data); - -``` +To run the backend, start `Acme.BookStore.DbMigrator` once (it seeds three sample authors and six sample books), then run `Acme.BookStore.HttpApi.Host`. The default `BookStore` permissions need to be granted to the `admin` user via the **Identity → Roles → admin → Permissions** screen of the web UI before testing on mobile. -### Add the `Book Store` menu item to the navigation +If you want to follow the backend implementation step by step instead, read the [Web Application Development tutorial](../../book-store/part-01.md). The mobile-side code below works against the API surface listed above regardless of how you produced it. -For createing a menu item, navigate to `./src/navigators/DrawerNavigator.tsx` file and add `BookStoreStack` to `Drawer.Navigator` component. +## Adding the Book API Proxy -```tsx -//Other imports.. -import BookStoreStackNavigator from './BookStoreNavigator'; - -const Drawer = createDrawerNavigator(); - -export default function DrawerNavigator() { - return ( - - {/*Added Screen*/} - null }}}%} - /> - {/*Added Screen*/} - - ); -} -``` +There is no dynamic proxy generation for the React Native application, so we create the `BookAPI` proxy manually under `./src/api`. -Create the `BookStoreStackNavigator` inside `./src/navigators/BookStoreNavigator.tsx`, this navigator will be used for the BookStore menu item. +```ts +// ./src/api/BookAPI.ts +import api from './API'; -```tsx -import { createNativeStackNavigator } from '@react-navigation/native-stack'; -import { Button } from 'react-native-paper'; -import i18n from 'i18n-js'; +export const getList = (params: { maxResultCount?: number; skipCount?: number; sorting?: string } = {}) => + api.get('/api/app/book', { params }).then(({ data }) => data); -import { BookStoreScreen, CreateUpdateAuthorScreen, CreateUpdateBookScreen } from '../screens'; +export const get = (id: string) => + api.get(`/api/app/book/${id}`).then(({ data }) => data); -import { HamburgerIcon } from '../components'; -import { useThemeColors } from '../hooks'; +export const create = (input: any) => + api.post('/api/app/book', input).then(({ data }) => data); -const Stack = createNativeStackNavigator(); +export const update = (input: any, id: string) => + api.put(`/api/app/book/${id}`, input).then(({ data }) => data); -export default function BookStoreStackNavigator() { - const { background, onBackground } = useThemeColors(); +export const remove = (id: string) => + api.delete(`/api/app/book/${id}`).then(({ data }) => data); - return ( - - ({ - title: i18n.t('BookStore::Menu:BookStore'), - headerLeft: () => , - headerStyle: { backgroundColor: background }, - headerTintColor: onBackground, - headerShadowVisible: false, - })} - /> - ({ - title: i18n.t(route.params?.bookId ? 'BookStore::Edit' : 'BookStore::NewBook'), - headerRight: () => ( - - ), - headerStyle: { backgroundColor: background }, - headerTintColor: onBackground, - headerShadowVisible: false, - })} - /> - - ); -} +export const getAuthorLookup = () => + api.get('/api/app/book/author-lookup').then(({ data }) => data); ``` -- BookStoreScreen will be used to store the `books` and `authors` page +We will create `./src/api/AuthorAPI.ts` later in the [Author Section](#author). -Add the `BookStoreStack` to the screens object in the `./src/components/DrawerContent/DrawerContent.tsx` file. The DrawerContent component will be used to render the menu items. +- `api` is the shared `axios` instance (`./src/api/API.ts`) that injects the access token via the request interceptor in `./src/interceptors/APIInterceptor.ts`. +- `getList` accepts a paging payload (`maxResultCount`, `skipCount`, `sorting`) so it can be plugged into the `DataList` component we build next. -```tsx -// Imports.. -const screens = { - HomeStack: { label: "::Menu:Home", iconName: "home" }, - DashboardStack: { - label: "::Menu:Dashboard", - requiredPolicy: "BookStore.Dashboard", - iconName: "chart-areaspline", - }, - UsersStack: { - label: "AbpIdentity::Users", - iconName: "account-supervisor", - requiredPolicy: "AbpIdentity.Users", - }, - //Add this property - BookStoreStack: { - label: "BookStore::Menu:BookStore", - iconName: "book", - }, - //Add this property - TenantsStack: { - label: "Saas::Tenants", - iconName: "book-outline", - requiredPolicy: "Saas.Tenants", - }, - SettingsStack: { - label: "AbpSettingManagement::Settings", - iconName: "cog", - navigation: null, - }, -}; -// Other codes.. -``` +## Building the DataList Component -![Book Store Menu Item](../../../images/book-store-menu-item.png) - -### Create Book List page - -Before creating the book list page, we need to create the `BookStoreScreen.tsx` file under the `./src/screens/BookStore` folder. This file will be used to store the `books` and `authors` page. +The earlier React Native template shipped a `DataList` component on top of React Native Paper. The new template only ships the essentials (`FormButtons`, `Loading`, `ValidationMessage`), so we add a NativeWind-based equivalent under `./src/components/DataList`. ```tsx -import { useState, useEffect } from 'react'; -import { useSelector } from 'react-redux'; -import i18n from 'i18n-js'; -import { BottomNavigation } from 'react-native-paper'; - -import { BooksScreen } from '../../screens'; +// ./src/components/DataList/DataList.tsx +import { useCallback, useContext, useEffect, useState } from 'react'; +import { View, Text, FlatList, RefreshControl, ActivityIndicator } from 'react-native'; +import { LocalizationContext } from '../../contexts/LocalizationContext'; import { useThemeColors } from '../../hooks'; -const BooksRoute = nav => ; +interface DataListProps { + fetchFn: (params: { maxResultCount: number; skipCount: number }) => Promise<{ items: T[]; totalCount: number }>; + render: (info: { item: T; index: number }) => React.ReactElement; + trigger?: any; + pageSize?: number; +} -function BookStoreScreen({ navigation }) { - const [index, setIndex] = React.useState(0); - const [routes] = React.useState([ - { - key: "books", - title: i18n.t("BookStore::Menu:Books"), - focusedIcon: "book", - unfocusedIcon: "book-outline", +function DataList({ + fetchFn, + render, + trigger, + pageSize = 20, +}: DataListProps) { + const { t } = useContext(LocalizationContext); + const { accentColor } = useThemeColors(); + + const [items, setItems] = useState([]); + const [totalCount, setTotalCount] = useState(0); + const [skipCount, setSkipCount] = useState(0); + const [loading, setLoading] = useState(false); + const [refreshing, setRefreshing] = useState(false); + + const loadPage = useCallback( + async (skip: number, append: boolean) => { + if (loading) return; + setLoading(true); + try { + const result = await fetchFn({ maxResultCount: pageSize, skipCount: skip }); + const fetched = result?.items ?? []; + setTotalCount(result?.totalCount ?? 0); + setItems(prev => (append ? [...prev, ...fetched] : fetched)); + setSkipCount(skip + fetched.length); + } catch (e) { + if (!append) setItems([]); + } finally { + setLoading(false); + } }, - ]); - - const renderScene = BottomNavigation.SceneMap({ - books: BooksRoute, - }); - - return ( - + [fetchFn, pageSize, loading], ); -} -export default BookStoreScreen; -``` - -Create the `BooksScreen.tsx` file under the `./src/screens/BookStore/Books` folder. -```tsx -import { useSelector } from "react-redux"; -import { View } from "react-native"; -import { List } from "react-native-paper"; -import { getBooks } from "../../api/BookAPI"; -import i18n from "i18n-js"; -import DataList from "../../components/DataList/DataList"; -import { createAppConfigSelector } from "../../store/selectors/AppSelectors"; -import { useThemeColors } from '../../../hooks'; - -function BooksScreen({ navigation }) { - const { background, primary } = useThemeColors(); - const currentUser = useSelector(createAppConfigSelector())?.currentUser; + useEffect(() => { + setSkipCount(0); + loadPage(0, false); + }, [trigger]); + + const onRefresh = useCallback(async () => { + setRefreshing(true); + await loadPage(0, false); + setRefreshing(false); + }, [loadPage]); + + const onEndReached = useCallback(() => { + if (loading || refreshing) return; + if (items.length >= totalCount) return; + loadPage(skipCount, true); + }, [loading, refreshing, items.length, totalCount, skipCount, loadPage]); return ( - - {currentUser?.isAuthenticated && ( - ( - - )} - /> + item?.id?.toString() ?? index.toString()} + renderItem={render} + contentContainerStyle={{ flexGrow: 1, paddingBottom: 96 }} + refreshControl={} + onEndReached={onEndReached} + onEndReachedThreshold={0.4} + ItemSeparatorComponent={() => ( + )} - + ListEmptyComponent={ + loading ? null : ( + + + {t('AbpUi::NoData')} + + + ) + } + ListFooterComponent={ + loading && items.length > 0 ? ( + + + + ) : null + } + /> ); } -export default BooksScreen; -``` - -- `getBooks` function is used to fetch the books from the server. -- `i18n` API to localize the given key. It uses the incoming resource from the `application-localization` endpoint. -- `DataList` component takes the `fetchFn` property that we'll give to the API request function, it's used to fetch data and maintain the logic of lazy loading etc. -![Book List Page](../../../images/book-list.png) - -## Creating a New Book - -### Add the `@react-native-community/datetimepicker` package for the date functionality. - -```bash -yarn expo install @react-native-community/datetimepicker - -//or - -npx expo install @react-native-community/datetimepicker +export default DataList; ``` -### Add the `CreateUpdateBook` Screen to the BookStoreNavigator - -Like the `BookStoreScreen` we need to add the `CreateUpdateBookScreen` to the `./src/navigators/BookStoreNavigator.tsx` file. - -```tsx -//Other codes - -import { Button } from "react-native-paper"; //Added this line +- `fetchFn` is any function that accepts `{ maxResultCount, skipCount }` and returns `{ items, totalCount }` — the shape of every ABP `ICrudAppService.GetListAsync` response. +- `trigger` is an arbitrary value: pass a counter that you increment (`setRefresh(r => r + 1)`) after a delete or save and the list re-fetches from page zero. +- The pull-to-refresh and the lazy "load more on end reached" behavior are built in. -import { CreateUpdateBookScreen } from '../screens'; //Added this line +## Building the AbpSelect Component -//Other codes - -export default function BookStoreStackNavigator() { - return ( - - {/*Other screens*/} - {/* Added this screen */} - ({ - title: i18n.t( - route.params?.bookId ? "BookStore::Edit" : "BookStore::NewBook" - ), - headerRight: () => ( - - ), - headerStyle: { backgroundColor: background }, - headerTintColor: onBackground, - headerShadowVisible: false, - })} - /> - - ); -} -``` - -To navigate to the `CreateUpdateBookScreen`, we need to add the `CreateUpdateBook` button to the `BooksScreen.tsx` file. +For dropdowns (book type, author selection) we build a small modal-based picker, also under `./src/components`. ```tsx -//Other imports.. - -import { - // rest imports.., - StyleSheet, -} from "react-native"; - -import { - // rest imports.., - AnimatedFAB, -} from "react-native-paper"; - -function BooksScreen({ navigation }) { - //Other codes.. +// ./src/components/AbpSelect/AbpSelect.tsx +import { useContext } from 'react'; +import { Modal, View, Text, Pressable, FlatList } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { LocalizationContext } from '../../contexts/LocalizationContext'; +import { useThemeColors } from '../../hooks'; - return ( - - {/* Other codes..*/} - - {/* Included Code */} - {currentUser?.isAuthenticated && ( - navigation.navigate("CreateUpdateBook")} - visible={true} - animateFrom={"right"} - iconMode={"static"} - style={[styles.fabStyle, { backgroundColor: primary }]} - /> - )} - {/* Included Code */} - - ); +export interface AbpSelectItem { + id: string | number; + displayName: string; } -//Added lines -const styles = StyleSheet.create({ - container: { - flexGrow: 1, - }, - fabStyle: { - bottom: 16, - right: 16, - position: "absolute", - }, -}); -//Added lines - -export default BooksScreen; -``` - -After adding the `CreateUpdateBook` button, we need to add the `CreateUpdateBookScreen.tsx` file under the `./src/screens/BookStore/Books/CreateUpdateBook` folder. - -```tsx -import PropTypes from "prop-types"; - -import { create } from "../../../../api/BookAPI"; -import LoadingActions from "../../../../store/actions/LoadingActions"; -import { createLoadingSelector } from "../../../../store/selectors/LoadingSelectors"; -import { connectToRedux } from "../../../../utils/ReduxConnect"; -import CreateUpdateBookForm from "./CreateUpdateBookForm"; - -function CreateUpdateBookScreen({ navigation, startLoading, clearLoading }) { - const submit = (data) => { - startLoading({ key: "save" }); - - create(data) - .then(() => navigation.goBack()) - .finally(() => clearLoading()); - }; - - return ; +interface AbpSelectProps { + visible: boolean; + title: string; + items: AbpSelectItem[]; + selectedItem?: string | number; + hasDefaultItem?: boolean; + hideModalFn: () => void; + setSelectedItem: (id: any) => void; } -CreateUpdateBookScreen.propTypes = { - startLoading: PropTypes.func.isRequired, - clearLoading: PropTypes.func.isRequired, -}; - -export default connectToRedux({ - component: CreateUpdateBookScreen, - stateProps: (state) => ({ loading: createLoadingSelector()(state) }), - dispatchProps: { - startLoading: LoadingActions.start, - clearLoading: LoadingActions.clear, - }, -}); -``` - -- In this page we will store logic, send post/put requests, get the selected book data and etc. -- This page will wrap the `CreateUpdateBookFrom` component and pass the submit function with other properties. - -Create a `CreateUpdateBookForm.tsx` file under the `./src/screens/BookStore/Books/CreateUpdateBook` folder and add the following code to it. - -```tsx -import * as Yup from 'yup'; -import { useRef, useState } from 'react'; -import { Platform, KeyboardAvoidingView, StyleSheet, View, ScrollView } from 'react-native'; -import { useFormik } from 'formik'; -import i18n from 'i18n-js'; -import PropTypes from 'prop-types'; -import { TextInput, Portal, Modal, Text, Divider, Button } from 'react-native-paper'; -import DateTimePicker from '@react-native-community/datetimepicker'; - -import { FormButtons, ValidationMessage, AbpSelect } from '../../../../components'; -import { useThemeColors } from '../../../../hooks'; - - -const validations = { - name: Yup.string().required("AbpValidation::ThisFieldIsRequired."), - price: Yup.number().required("AbpValidation::ThisFieldIsRequired."), - type: Yup.string().nullable().required("AbpValidation::ThisFieldIsRequired."), - publishDate: Yup.string() - .nullable() - .required("AbpValidation::ThisFieldIsRequired."), -}; - -const props = { - underlineStyle: { backgroundColor: "transparent" }, - underlineColor: "#333333bf", -}; - -function CreateUpdateBookForm({ submit }) { - const { primaryContainer, background, onBackground } = useThemeColors(); - - const [bookTypeVisible, setBookTypeVisible] = useState(false); - const [publishDateVisible, setPublishDateVisible] = useState(false); - - const nameRef = useRef(null); - const priceRef = useRef(null); - const typeRef = useRef(null); - const publishDateRef = useRef(null); - - const inputStyle = { - ...styles.input, - backgroundColor: primaryContainer, - }; - const bookTypes = new Array(8).fill(0).map((_, i) => ({ - id: i + 1, - displayName: i18n.t(`BookStore::Enum:BookType.${i + 1}`), - })); - - const onSubmit = (values) => { - if (!bookForm.isValid) { - return; - } - - submit({ ...values }); - }; - - const bookForm = useFormik({ - enableReinitialize: true, - validateOnBlur: true, - validationSchema: Yup.object().shape({ - ...validations, - }), - initialValues: { - name: "", - price: "", - type: "", - publishDate: null, - }, - onSubmit, - }); - - const isInvalidControl = (controlName = null) => { - if (!controlName) { - return; - } - - return ( - ((!!bookForm.touched[controlName] && bookForm.submitCount > 0) || - bookForm.submitCount > 0) && - !!bookForm.errors[controlName] - ); - }; - - const onChange = (event, selectedDate) => { - if (!selectedDate) { - return; - } - - setPublishDateVisible(false); - - if (event && event.type !== "dismissed") { - bookForm.setFieldValue("publishDate", selectedDate, true); - } - }; +function AbpSelect({ + visible, + title, + items, + selectedItem, + hasDefaultItem = false, + hideModalFn, + setSelectedItem, +}: AbpSelectProps) { + const { t } = useContext(LocalizationContext); + const { accentColor } = useThemeColors(); + + const data = hasDefaultItem + ? [{ id: '', displayName: `-- ${t('AbpUi::PagerInfo:NoDataText')} --` } as AbpSelectItem, ...items] + : items; return ( - - setBookTypeVisible(false)} - selectedItem={bookForm.values.type} - setSelectedItem={(id) => { - bookForm.setFieldValue("type", id, true); - bookForm.setFieldValue( - "typeDisplayName", - bookTypes.find((f) => f.id === id)?.displayName || null, - false - ); - }} - /> - - - - - priceRef.current.focus()} - returnKeyType="next" - onChangeText={bookForm.handleChange('name')} - onBlur={bookForm.handleBlur('name')} - value={bookForm.values.name} - autoCapitalize="none" - label={i18n.t('BookStore::Name')} - style={inputStyle} - {...props} - /> - {isInvalidControl('name') && ( - {bookForm.errors.name as string} - )} + + + {}} + className="w-full max-w-md bg-card dark:bg-card-dark rounded-2xl border border-card-border dark:border-card-border-dark shadow-lg overflow-hidden"> + + + {title} + + + + - - typeRef.current.focus()} - returnKeyType="next" - onChangeText={bookForm.handleChange('price')} - onBlur={bookForm.handleBlur('price')} - value={bookForm.values.price} - autoCapitalize="none" - label={i18n.t('BookStore::Price')} - style={inputStyle} - {...props} - /> - {isInvalidControl('price') && ( - {bookForm.errors.price as string} + String(item.id)} + style={{ maxHeight: 360 }} + ItemSeparatorComponent={() => ( + )} - - - - setBookTypeVisible(true)} icon="menu-down" />} - style={inputStyle} - editable={false} - value={bookForm.values.typeDisplayName} - {...props} - /> - {isInvalidControl('type') && ( - {bookForm.errors.type as string} - )} - - - - setPublishDateVisible(true)} - icon="calendar" - iconColor={bookForm.values.publishDate ? '#4CAF50' : '#666'} - /> - } - style={inputStyle} - editable={false} - value={formatDate(bookForm.values.publishDate)} - placeholder="Select publish date" - {...props} - /> - {isInvalidControl('publishDate') && ( - {bookForm.errors.publishDate as string} - )} - - - - - - {i18n.t('BookStore::PublishDate')} - - - - - - - - - - - - - - + renderItem={({ item }) => { + const isSelected = String(item.id) === String(selectedItem ?? ''); + return ( + { + setSelectedItem(item.id); + hideModalFn(); + }} + className={`px-5 py-3.5 flex-row items-center justify-between ${ + isSelected ? 'bg-secondary dark:bg-secondary-dark' : '' + }`}> + + {item.displayName} + + {isSelected ? : null} + + ); + }} + /> + + + ); } -const styles = StyleSheet.create({ - inputContainer: { - margin: 8, - marginLeft: 16, - marginRight: 16, - }, - input: { - borderRadius: 8, - borderTopLeftRadius: 8, - borderTopRightRadius: 8, - }, - button: { - marginLeft: 16, - marginRight: 16, - }, - dateModal: { - padding: 20, - margin: 20, - borderRadius: 12, - elevation: 5, - shadowColor: '#000', - shadowOffset: { - width: 0, - height: 2, - }, - shadowOpacity: 0.25, - shadowRadius: 3.84, - }, - modalTitle: { - textAlign: 'center', - marginBottom: 16, - fontWeight: '600', - }, - divider: { - marginBottom: 16, - }, - modalButtons: { - flexDirection: 'row', - justifyContent: 'space-between', - marginTop: 20, - paddingHorizontal: 8, - }, -}); - -CreateUpdateBookForm.propTypes = { - book: PropTypes.object, - authors: PropTypes.array.isRequired, - submit: PropTypes.func.isRequired, -}; - -export default CreateUpdateBookForm; +export default AbpSelect; ``` -- `formik` will manage the form state, validation and value changes. -- `Yup` allows for the build validation schema. -- `AbpSelect` component is used to select the book type. -- `submit` method will pass the form values to the `CreateUpdateBookScreen` component. - -![Create New Book Icon](../../../images/create-book-icon.png) - -![Create New Book](../../../images/create-book.png) - -## Update a Book - -We need the navigation parameter for getting the bookId and then navigate it again after the create & update operations. That is why we will pass the navigation parameter to the `BooksScreen` component. - -```tsx -//Imports.. - -//Add navigation parameter -const BooksRoute = (nav) => ; - -function BookStoreScreen({ navigation }) { - //Other codes.. - - const renderScene = BottomNavigation.SceneMap({ - books: () => BooksRoute(navigation), //Use this way - }); - - //Other codes.. -} - -export default BookStoreScreen; -``` - -Replace the code below in the `BookScreen.tsx` file under the `./src/screens/BookStore/Books` folder. - -```tsx -import { useState } from 'react'; -import { useSelector } from 'react-redux'; -import { Alert, View, StyleSheet } from 'react-native'; -import { List, IconButton, AnimatedFAB } from 'react-native-paper'; -import { useActionSheet } from '@expo/react-native-action-sheet'; -import i18n from 'i18n-js'; - -import { getList, remove } from '../../../api/BookAPI'; -import { DataList } from '../../../components'; -import { createAppConfigSelector } from '../../../store/selectors/AppSelectors'; -import { useThemeColors } from '../../../hooks'; - -function BooksScreen({ navigation }) { - const { background, primary } = useThemeColors(); - const currentUser = useSelector(createAppConfigSelector())?.currentUser; - const policies = useSelector(createAppConfigSelector())?.auth?.grantedPolicies; - - const [refresh, setRefresh] = useState(null); - const { showActionSheetWithOptions } = useActionSheet(); - - const openContextMenu = (item: { id: string }) => { - const options = []; - - if (policies['BookStore.Books.Delete']) { - options.push(i18n.t('AbpUi::Delete')); - } - - if (policies['BookStore.Books.Edit']) { - options.push(i18n.t('AbpUi::Edit')); - } - - options.push(i18n.t('AbpUi::Cancel')); - - showActionSheetWithOptions( - { - options, - cancelButtonIndex: options.length - 1, - destructiveButtonIndex: options.indexOf(i18n.t('AbpUi::Delete')), - }, - index => { - switch (options[index]) { - case i18n.t('AbpUi::Edit'): - edit(item); - break; - case i18n.t('AbpUi::Delete'): - removeOnClick(item); - break; - } - }, - ); - }; - - const removeOnClick = (item: { id: string }) => { - Alert.alert('Warning', i18n.t('BookStore::AreYouSureToDelete'), [ - { - text: i18n.t('AbpUi::Cancel'), - style: 'cancel', - }, - { - style: 'default', - text: i18n.t('AbpUi::Ok'), - onPress: () => { - remove(item.id).then(() => { - setRefresh((refresh ?? 0) + 1); - }); - }, - }, - ]); - }; - - const edit = (item: { id: string }) => { - navigation.navigate('CreateUpdateBook', { bookId: item.id }); - }; - - return ( - - {currentUser?.isAuthenticated && ( - ( - ( - openContextMenu(item)} - /> - )} - /> - )} - /> - )} +Now expose the two new components from the barrel file so screens can import them with a single statement: - {currentUser?.isAuthenticated && !!policies['BookStore.Books.Create'] && ( - navigation.navigate('CreateUpdateBook')} - visible={true} - animateFrom={'right'} - iconMode={'static'} - style={[styles.fabStyle, { backgroundColor: primary }]} - /> - )} - - ); -} - -const styles = StyleSheet.create({ - container: { - flexGrow: 1, - }, - fabStyle: { - bottom: 16, - right: 16, - position: 'absolute', - }, -}); - -export default BooksScreen; +```ts +// ./src/components/index.ts +export { default as FormButtons } from './FormButtons/FormButtons'; +export { default as ValidationMessage } from './ValidationMessage/ValidationMessage'; +export { default as DataList } from './DataList/DataList'; +export { default as AbpSelect } from './AbpSelect/AbpSelect'; +export type { AbpSelectItem } from './AbpSelect/AbpSelect'; ``` -Replace code below for `CreateUpdateBookScreen.tsx` file under the `./src/screens/BookStore/Books/CreateUpdateBook/` +## Creating the BookStoreNavigator -```tsx -import PropTypes from 'prop-types'; -import { useEffect, useState } from 'react'; - -import { getAuthorLookup, get, create, update } from '../../../../api/BookAPI'; -import LoadingActions from '../../../../store/actions/LoadingActions'; -import { createLoadingSelector } from '../../../../store/selectors/LoadingSelectors'; -import { connectToRedux } from '../../../../utils/ReduxConnect'; -import CreateUpdateBookForm from './CreateUpdateBookForm'; +The `BookStore` feature has three screens that share a stack: the list root (`BookStore`), `CreateUpdateBook`, and `CreateUpdateAuthor`. Add the route names to the typed navigator definitions first. -function CreateUpdateBookScreen({ navigation, route, startLoading, clearLoading }) { - const { bookId } = route.params || {}; - const [book, setBook] = useState(null); - - const submit = (data: any) => { - startLoading({ key: 'save' }); - - (data.id ? update(data, data.id) : create(data)) - .then(() => navigation.goBack()) - .finally(() => clearLoading()); - }; - - useEffect(() => { - if (bookId) { - startLoading({ key: 'fetchBookDetail' }); - - get(bookId) - .then((response: any) => setBook(response)) - .finally(() => clearLoading()); - } - }, [bookId]); - - return ; -} - -CreateUpdateBookScreen.propTypes = { - startLoading: PropTypes.func.isRequired, - clearLoading: PropTypes.func.isRequired, +```ts +// ./src/navigators/types.ts (additions) +export type BookStoreStackParamList = { + BookStore: undefined; + CreateUpdateBook: { bookId?: string } | undefined; + CreateUpdateAuthor: { authorId?: string } | undefined; }; -export default connectToRedux({ - component: CreateUpdateBookScreen, - stateProps: state => ({ loading: createLoadingSelector()(state) }), - dispatchProps: { - startLoading: LoadingActions.start, - clearLoading: LoadingActions.clear, - }, -}); +export type BookStoreScreenProps = NativeStackScreenProps; +export type CreateUpdateBookScreenProps = NativeStackScreenProps; +export type CreateUpdateAuthorScreenProps = NativeStackScreenProps; ``` -- `get` method is used to fetch the book details from the server. -- `update` method is used to update the book on the server. -- `route` parameter will be used to get the bookId from the navigation. - -Replace the `CreateUpdateBookForm.tsx` file with the code below. We will use this file for the create and update operations. - -```tsx -//Imports.. - -//validateSchema - -//props - -function CreateUpdateBookForm({ - submit, - book = null, //Add book parameter with default value -}) { - //Other codes.. - - const bookForm = useFormik({ - enableReinitialize: true, - validateOnBlur: true, - validationSchema: Yup.object().shape({ - ...validations, - }), - initialValues: { - //Update initialValues - ...book, - name: book?.name || "", - price: book?.price.toString() || "", - type: book?.type || "", - typeDisplayName: - book?.type && i18n.t("BookStore::Enum:BookType." + book.type), - publishDate: (book?.publishDate && new Date(book?.publishDate)) || null, - //Update initialValues - }, - onSubmit, - }); - - //Others codes.. -} - -//Other codes.. -``` - -- `book` is a nullable property. It will store the selected book, if the book parameter is null then we will create a new book. - -![Book List With Options](../../../images/book-list-with-options.png) - -![Update Book Page](../../../images/update-book.png) - -## Delete a Book - -Replace the code below in the `BooksScreen.tsx` file under the `./src/screens/BookStore/Books` folder. - -```tsx -import { useState } from 'react'; -import { useSelector } from 'react-redux'; -import { Alert, View, StyleSheet } from 'react-native'; -import { List, IconButton, AnimatedFAB } from 'react-native-paper'; -import { useActionSheet } from '@expo/react-native-action-sheet'; -import i18n from 'i18n-js'; - -import { getList, remove } from '../../../api/BookAPI'; -import { DataList } from '../../../components'; -import { createAppConfigSelector } from '../../../store/selectors/AppSelectors'; -import { useThemeColors } from '../../../hooks'; - -function BooksScreen({ navigation }) { - const { background, primary } = useThemeColors(); - const currentUser = useSelector(createAppConfigSelector())?.currentUser; - const policies = useSelector(createAppConfigSelector())?.auth?.grantedPolicies; - - const [refresh, setRefresh] = useState(null); - const { showActionSheetWithOptions } = useActionSheet(); - - const openContextMenu = (item: { id: string }) => { - const options = []; - - if (policies['BookStore.Books.Delete']) { - options.push(i18n.t('AbpUi::Delete')); - } - - if (policies['BookStore.Books.Edit']) { - options.push(i18n.t('AbpUi::Edit')); - } - - options.push(i18n.t('AbpUi::Cancel')); - - showActionSheetWithOptions( - { - options, - cancelButtonIndex: options.length - 1, - destructiveButtonIndex: options.indexOf(i18n.t('AbpUi::Delete')), - }, - index => { - switch (options[index]) { - case i18n.t('AbpUi::Edit'): - edit(item); - break; - case i18n.t('AbpUi::Delete'): - removeOnClick(item); - break; - } - }, - ); - }; - - const removeOnClick = (item: { id: string }) => { - Alert.alert('Warning', i18n.t('BookStore::AreYouSureToDelete'), [ - { - text: i18n.t('AbpUi::Cancel'), - style: 'cancel', - }, - { - style: 'default', - text: i18n.t('AbpUi::Ok'), - onPress: () => { - remove(item.id).then(() => { - setRefresh((refresh ?? 0) + 1); - }); - }, - }, - ]); - }; - - const edit = (item: { id: string }) => { - navigation.navigate('CreateUpdateBook', { bookId: item.id }); - }; - - return ( - - {currentUser?.isAuthenticated && ( - ( - ( - openContextMenu(item)} - /> - )} - /> - )} - /> - )} - - {currentUser?.isAuthenticated && !!policies['BookStore.Books.Create'] && ( - navigation.navigate('CreateUpdateBook')} - visible={true} - animateFrom={'right'} - iconMode={'static'} - style={[styles.fabStyle, { backgroundColor: primary }]} - /> - )} - - ); -} - -const styles = StyleSheet.create({ - container: { - flexGrow: 1, - }, - fabStyle: { - bottom: 16, - right: 16, - position: 'absolute', - }, -}); - -export default BooksScreen; -``` - -- `Delete` option is added to context menu list -- `removeOnClick` method will handle the delete process. It'll show an alert before the delete operation. - -![Delete Book](../../../images/delete-book.png) - -![Delete Book Alert](../../../images/delete-book-alert.png) - -## Authorization - -### Hide Books item in tab - -Add `grantedPolicies` to the policies variable from the `appConfig` store - -```tsx -//Other imports.. -import { useSelector } from "react-redux"; - -function BookStoreScreen({ navigation }) { - const [index, setIndex] = React.useState(0); - const [routes, setRoutes] = React.useState([]); - - const currentUser = useSelector((state) => state.app.appConfig.currentUser); - const policies = useSelector( - (state) => state.app.appConfig.auth.grantedPolicies - ); - - const renderScene = BottomNavigation.SceneMap({ - books: () => BooksRoute(navigation), - }); - - React.useEffect(() => { - if (!currentUser?.isAuthenticated || !policies) { - setRoutes([]); - return; - } - - let _routes = []; - - if (!!policies["BookStore.Books"]) { - _routes.push({ - key: "books", - title: i18n.t("BookStore::Menu:Books"), - focusedIcon: "book", - unfocusedIcon: "book-outline", - }); - } - - setRoutes([..._routes]); - }, [Object.keys(policies)?.filter((f) => f.startsWith("BookStore")).length]); - - return ( - routes?.length > 0 && ( - - ) - ); -} - -export default BookStoreScreen; -``` - -- In the `useEffect` function we'll check the `currentUser` and `policies` variables. -- useEffect's conditions will be the policies of the `BookStore` permission group. -- `Books` tab will be shown if the user has the `BookStore.Books` permission - -![Books Menu Item](../../../images/books-menu-item.png) +Also extend `BottomTabParamList`: -### Hide the New Book Button +```ts +export type BottomTabParamList = { + HomeTab: undefined; + BookStoreTab: undefined; + SettingsTab: undefined; + AccountTab: undefined; +}; +``` -`New Book` button is placed in the BooksScreen as a `+` icon button. For the toggle visibility of the button, we need to add the `policies` variable to the `BooksScreen` component like the `BookStoreScreen` component. Open the `BooksScreen.tsx` file in the `./src/screens/BookStore/Books` folder and include the code below. +Then create the stack navigator: ```tsx -//Imports.. +// ./src/navigators/BookStoreNavigator.tsx +import { useContext } from 'react'; +import { Pressable, Text } from 'react-native'; +import { createNativeStackNavigator } from '@react-navigation/native-stack'; -function BooksScreen({ navigation }) { - const policies = useSelector(createAppConfigSelector())?.auth?.grantedPolicies; +import { useThemeColors } from '../hooks'; +import { LocalizationContext } from '../contexts/LocalizationContext'; +import { + BookStoreScreen, + CreateUpdateBookScreen, + CreateUpdateAuthorScreen, +} from '../screens'; +import type { BookStoreStackParamList } from './types'; - //Other codes.. +const Stack = createNativeStackNavigator(); + +export default function BookStoreStackNavigator() { + const { headerBg, headerText, accentColor } = useThemeColors(); + const { t } = useContext(LocalizationContext); return ( - {/*Other codes..*/} - - {currentUser?.isAuthenticated && - !!policies['BookStore.Books.Create'] && //Add this line - ( - navigation.navigate('CreateUpdateBook')} - visible={true} - animateFrom={'right'} - iconMode={'static'} - style={[styles.fabStyle, { backgroundColor: primary }]} - /> - ) - } - ) + + + ({ + title: t(route.params?.bookId ? 'BookStore::Edit' : 'BookStore::NewBook'), + headerStyle: { backgroundColor: headerBg }, + headerTintColor: headerText, + headerShadowVisible: false, + headerRight: () => ( + navigation.goBack()} hitSlop={8}> + {t('AbpUi::Cancel')} + + ), + })} + /> + ({ + title: t(route.params?.authorId ? 'BookStore::Edit' : 'BookStore::NewAuthor'), + headerStyle: { backgroundColor: headerBg }, + headerTintColor: headerText, + headerShadowVisible: false, + headerRight: () => ( + navigation.goBack()} hitSlop={8}> + {t('AbpUi::Cancel')} + + ), + })} + /> + + ); } ``` -- Now the `+` icon button will be shown if the user has the `BookStore.Books.Create` permission. - -![Create New Book Button Policy](../../../images/create-book-button-visibility.png) +The screens referenced in the imports above will be created in the next sections. -### Hide the Edit and Delete Actions +## Adding BookStore to the BottomTabNavigator -Update your code as below in the `./src/screens/BookStore/Books/BooksScreen.tsx` file. We'll check the `policies` variables for the `Edit` and `Delete` actions. +Open `./src/navigators/BottomTabNavigator.tsx` and add a `BookStoreTab` between `HomeTab` and `SettingsTab`. The tab is shown only when the user has at least one of the BookStore permissions: ```tsx -function BooksScreen() { - //... - - const openContextMenu = (item) => { - const options = []; +// ./src/navigators/BottomTabNavigator.tsx +import { useContext } from 'react'; +import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; +import { Ionicons } from '@expo/vector-icons'; +import { useSelector } from 'react-redux'; - if (policies["BookStore.Books.Delete"]) { - options.push(i18n.t("AbpUi::Delete")); - } +import { useThemeColors } from '../hooks'; +import { LocalizationContext } from '../contexts/LocalizationContext'; +import { appConfigSelector } from '../store/selectors/AppSelectors'; - if (policies["BookStore.Books.Update"]) { - options.push(i18n.t("AbpUi::Edit")); - } +import HomeStackNavigator from './HomeNavigator'; +import SettingsStackNavigator from './SettingsNavigator'; +import AccountStackNavigator from './AccountNavigator'; +import BookStoreStackNavigator from './BookStoreNavigator'; - options.push(i18n.t("AbpUi::Cancel")); - }; +const Tab = createBottomTabNavigator(); - //... -} -``` +export default function BottomTabNavigator() { + const { headerBg, accentColor, iconColor } = useThemeColors(); + const { t } = useContext(LocalizationContext); -![Create New Book Button Policy](../../../images/update-delete-book-button-visibility.png) + const policies = useSelector(appConfigSelector)?.auth?.grantedPolicies ?? {}; + const showBookStore = !!policies['BookStore.Books'] || !!policies['BookStore.Authors']; -## Author + return ( + + + + {showBookStore ? ( + ( + + ), + }} + /> + ) : null} -### Create API Proxy + + + + ); +} +``` -```ts -//./src/api/AuthorAPI.ts +> Earlier versions of the template used a `DrawerNavigator`. The 2026 template defaults to `bottom-tab` instead. If your project still uses the drawer (the optional `navigation_type = "drawer"` configuration), add the same conditional `Drawer.Screen` to `DrawerNavigator.tsx` instead. -import api from './API'; + +![Book Store Tab](../../../images/book-store-menu-item.png) -export const getList = () => api.get('/api/app/author').then(({ data }) => data); +## Creating the BookStoreScreen -export const get = id => api.get(`/api/app/author/${id}`).then(({ data }) => data); +`BookStoreScreen` is the root of the stack. It hosts a small NativeWind-based tab header that switches between the **Books** and **Authors** lists. Each tab is rendered only if the user has the corresponding permission. -export const create = input => api.post('/api/app/author', input).then(({ data }) => data); +```tsx +// ./src/screens/BookStore/BookStoreScreen.tsx +import { useContext, useEffect, useMemo, useState } from 'react'; +import { View, Text, Pressable } from 'react-native'; +import { useSelector } from 'react-redux'; -export const update = (input, id) => api.put(`/api/app/author/${id}`, input).then(({ data }) => data); +import { LocalizationContext } from '../../contexts/LocalizationContext'; +import { appConfigSelector } from '../../store/selectors/AppSelectors'; +import type { BookStoreScreenProps } from '../../navigators/types'; -export const remove = id => api.delete(`/api/app/author/${id}`).then(({ data }) => data); -``` +import BooksScreen from './Books/BooksScreen'; +import AuthorsScreen from './Authors/AuthorsScreen'; -## The Author List Page +type TabKey = 'books' | 'authors'; +interface TabDef { key: TabKey; label: string; } -### Add Authors Tab to BookStoreScreen +function BookStoreScreen({ navigation }: BookStoreScreenProps) { + const { t } = useContext(LocalizationContext); + const policies = useSelector(appConfigSelector)?.auth?.grantedPolicies ?? {}; -Open the `./src/screens/BookStore/BookStoreScreen.tsx` file and update it with the code below. + const tabs = useMemo(() => { + const list: TabDef[] = []; + if (policies['BookStore.Books']) list.push({ key: 'books', label: t('BookStore::Menu:Books') }); + if (policies['BookStore.Authors']) list.push({ key: 'authors', label: t('BookStore::Menu:Authors') }); + return list; + }, [policies, t]); -```tsx -//Other imports -import AuthorsScreen from "./Authors/AuthorsScreen"; + const [activeKey, setActiveKey] = useState(tabs[0]?.key); -//Other Routes.. -const AuthorsRoute = (nav) => ; + useEffect(() => { + if (!tabs.find(tab => tab.key === activeKey)) setActiveKey(tabs[0]?.key); + }, [tabs, activeKey]); -function BookStoreScreen({ navigation }) { - //Other codes.. + if (tabs.length === 0) { + return ( + + + {t('BookStore::NoAccess')} + + + ); + } - const renderScene = BottomNavigation.SceneMap({ - books: () => BooksRoute(navigation), - authors: () => AuthorsRoute(navigation), //Added this line - }); + return ( + + + {tabs.map(tab => { + const isActive = activeKey === tab.key; + return ( + setActiveKey(tab.key)} + className={`flex-1 py-3 items-center border-b-2 ${ + isActive ? 'border-accent dark:border-accent-dark' : 'border-transparent' + }`}> + + {tab.label} + + + ); + })} + - //Added this - if (!!policies["BookStore.Authors"]) { - _routes.push({ - key: "authors", - title: i18n.t("BookStore::Menu:Authors"), - focusedIcon: "account-supervisor", - unfocusedIcon: "account-supervisor-outline", - }); - } - //Added this + + {activeKey === 'books' ? : null} + {activeKey === 'authors' ? : null} + + + ); } export default BookStoreScreen; ``` -Create a `AuthorsScreen.tsx` file under the `./src/screens/BookStore/Authors` folder and add the code below to it. +The previous template used `react-native-paper`'s `BottomNavigation` for this. Building the tab strip with two `Pressable`s and NativeWind classes keeps the rest of the screen consistent with the modernized look and avoids paying for an extra Paper component in the bundle. + +## The Book List Page + +Create `./src/screens/BookStore/Books/BooksScreen.tsx`. The list itself is a single `DataList`. Each row is a `Pressable` that opens an action sheet with **Edit** and **Delete** entries — both gated by the corresponding permission. The floating "+" button at the bottom right is rendered only when the user has `BookStore.Books.Create`. ```tsx -import { useState } from 'react'; +// ./src/screens/BookStore/Books/BooksScreen.tsx +import { useContext, useState } from 'react'; +import { Alert, View, Text, Pressable } from 'react-native'; import { useSelector } from 'react-redux'; -import { Alert, View, StyleSheet } from 'react-native'; -import { List, IconButton, AnimatedFAB } from 'react-native-paper'; import { useActionSheet } from '@expo/react-native-action-sheet'; -import i18n from 'i18n-js'; +import { Ionicons } from '@expo/vector-icons'; -import { getList, remove } from '../../../api/AuthorAPI'; -import { DataList } from '../../../components'; -import { createAppConfigSelector } from '../../../store/selectors/AppSelectors'; +import { LocalizationContext } from '../../../contexts/LocalizationContext'; +import { appConfigSelector } from '../../../store/selectors/AppSelectors'; import { useThemeColors } from '../../../hooks'; +import { DataList } from '../../../components'; +import { getList, remove } from '../../../api/BookAPI'; +import type { BookStoreScreenProps } from '../../../navigators/types'; -function AuthorsScreen({ navigation }) { - const { background, primary } = useThemeColors(); - const currentUser = useSelector(createAppConfigSelector())?.currentUser; - const policies = useSelector(createAppConfigSelector())?.auth?.grantedPolicies; +interface BookListItem { + id: string; + name: string; + authorName: string; + type: number; +} - const [refresh, setRefresh] = useState(null); - const { showActionSheetWithOptions } = useActionSheet(); +interface BooksScreenInnerProps { navigation: BookStoreScreenProps['navigation']; } - const openContextMenu = (item: { id: string }) => { - const options = []; +function BooksScreen({ navigation }: BooksScreenInnerProps) { + const { t } = useContext(LocalizationContext); + const { accentColor, iconColor } = useThemeColors(); + const policies = useSelector(appConfigSelector)?.auth?.grantedPolicies ?? {}; - if (policies['BookStore.Authors.Delete']) { - options.push(i18n.t('AbpUi::Delete')); - } + const [refresh, setRefresh] = useState(0); + const { showActionSheetWithOptions } = useActionSheet(); - if (policies['BookStore.Authors.Edit']) { - options.push(i18n.t('AbpUi::Edit')); - } + const canCreate = !!policies['BookStore.Books.Create']; + const canEdit = !!policies['BookStore.Books.Edit']; + const canDelete = !!policies['BookStore.Books.Delete']; - options.push(i18n.t('AbpUi::Cancel')); + const openContextMenu = (item: BookListItem) => { + const options: string[] = []; + if (canEdit) options.push(t('BookStore::Edit')); + if (canDelete) options.push(t('AbpUi::Delete')); + options.push(t('AbpUi::Cancel')); showActionSheetWithOptions( { options, cancelButtonIndex: options.length - 1, - destructiveButtonIndex: options.indexOf(i18n.t('AbpUi::Delete')), + destructiveButtonIndex: canDelete ? options.indexOf(t('AbpUi::Delete')) : undefined, }, - (index: number) => { - switch (options[index]) { - case i18n.t('AbpUi::Edit'): - edit(item); - break; - case i18n.t('AbpUi::Delete'): - removeOnClick(item); - break; - } + (index?: number) => { + if (index === undefined) return; + const selected = options[index]; + if (selected === t('BookStore::Edit')) navigation.navigate('CreateUpdateBook', { bookId: item.id }); + else if (selected === t('AbpUi::Delete')) confirmDelete(item); }, ); }; - const removeOnClick = ({ id }: { id: string }) => { - Alert.alert('Warning', i18n.t('BookStore::AreYouSureToDelete'), [ - { - text: i18n.t('AbpUi::Cancel'), - style: 'cancel', - }, + const confirmDelete = (item: BookListItem) => { + Alert.alert(t('AbpUi::AreYouSure'), t('BookStore::AreYouSureToDelete'), [ + { text: t('AbpUi::Cancel'), style: 'cancel' }, { - style: 'default', - text: i18n.t('AbpUi::Ok'), - onPress: () => { - remove(id).then(() => { - setRefresh((refresh ?? 0) + 1); - }); + text: t('AbpUi::Ok'), + style: 'destructive', + onPress: async () => { + await remove(item.id); + setRefresh(prev => prev + 1); }, }, ]); }; - const edit = ({ id }: { id: string }) => { - navigation.navigate('CreateUpdateAuthor', { authorId: id }); - }; - return ( - - {currentUser?.isAuthenticated && ( - ( - ( - openContextMenu(item)} - /> - )} - /> - )} - /> - )} + + + fetchFn={getList as any} + trigger={refresh} + render={({ item }) => ( + (canEdit || canDelete) && openContextMenu(item)} + className="px-4 py-3.5 active:bg-secondary dark:active:bg-secondary-dark"> + + + + {item.name} + + + {item.authorName} · {t(`BookStore::Enum:BookType:${item.type}`)} + + + {(canEdit || canDelete) ? ( + + ) : null} + + + )} + /> - {currentUser?.isAuthenticated && policies['BookStore.Authors.Create'] && ( - navigation.navigate('CreateUpdateAuthor')} - visible={true} - animateFrom={'right'} - iconMode={'static'} - style={[styles.fabStyle, { backgroundColor: primary }]} - /> - )} + {canCreate ? ( + navigation.navigate('CreateUpdateBook')} + className="absolute right-5 bottom-5 rounded-full px-5 py-3.5 flex-row items-center shadow-lg bg-accent dark:bg-accent-dark active:opacity-90"> + + + {t('BookStore::NewBook')} + + + ) : null} ); } -const styles = StyleSheet.create({ - container: { - flexGrow: 1, - }, - fabStyle: { - bottom: 16, - right: 16, - position: 'absolute', - }, -}); +export default BooksScreen; +``` + +- Reading `appConfigSelector` from the Redux store (no `connectToRedux` HOC) is the new pattern. The selector lives in `./src/store/selectors/AppSelectors.ts` and returns the `appConfig` object that the template populates after login (`AppActions.fetchAppConfigAsync`). +- `useActionSheet` is provided by `@expo/react-native-action-sheet`, already wrapped around the app in `./src/AppContent.tsx`, so we don't need to add a provider here. -export default AuthorsScreen; + +![Book List Page](../../../images/book-list.png) + +## Creating a New Book + +The book form needs `@react-native-community/datetimepicker` for the publish-date field. Install it: + +```bash +npx expo install @react-native-community/datetimepicker ``` -Create a `CreateUpdateAuthorScreen.tsx` file under the `./src/screens/BookStore/Authors/CreateUpdateAuthor` folder and add the code below to it. +Then create the screen + form pair under `./src/screens/BookStore/Books/CreateUpdateBook/`. + +### CreateUpdateBookScreen + +This component wires Redux loading + API calls and forwards data to the form. ```tsx -import PropTypes from 'prop-types'; +// ./src/screens/BookStore/Books/CreateUpdateBook/CreateUpdateBookScreen.tsx import { useEffect, useState } from 'react'; +import { useDispatch } from 'react-redux'; -import { get, create, update } from '../../../../api/AuthorAPI'; +import { get, create, update, getAuthorLookup } from '../../../../api/BookAPI'; import LoadingActions from '../../../../store/actions/LoadingActions'; -import { createLoadingSelector } from '../../../../store/selectors/LoadingSelectors'; -import { connectToRedux } from '../../../../utils/ReduxConnect'; -import CreateUpdateAuthorForm from './CreateUpdateAuthorForm'; - -function CreateUpdateAuthorScreen({ navigation, route, startLoading, clearLoading }) { - const { authorId } = route.params || {}; - const [ author, setAuthor ] = useState(null); +import type { CreateUpdateBookScreenProps } from '../../../../navigators/types'; +import type { AbpSelectItem } from '../../../../components'; +import CreateUpdateBookForm, { type BookFormValues } from './CreateUpdateBookForm'; - const submit = (data: any) => { - startLoading({ key: 'save' }); +function CreateUpdateBookScreen({ navigation, route }: CreateUpdateBookScreenProps) { + const { bookId } = route.params || {}; + const dispatch = useDispatch(); - (data.id ? update(data, data.id) : create(data)) - .then(() => navigation.goBack()) - .finally(() => clearLoading()); - }; + const [book, setBook] = useState(null); + const [authors, setAuthors] = useState([]); useEffect(() => { - if (authorId) { - startLoading({ key: 'fetchAuthorDetail' }); + let cancelled = false; + (async () => { + dispatch(LoadingActions.start({ key: 'fetchAuthorLookup' })); + try { + const result = await getAuthorLookup(); + if (cancelled) return; + setAuthors((result?.items ?? []).map((a: any) => ({ id: a.id, displayName: a.name }))); + } finally { + dispatch(LoadingActions.clear()); + } + })(); + return () => { cancelled = true; }; + }, [dispatch]); - get(authorId) - .then((response: any) => setAuthor(response)) - .finally(() => clearLoading()); + useEffect(() => { + if (!bookId) return; + let cancelled = false; + (async () => { + dispatch(LoadingActions.start({ key: 'fetchBookDetail' })); + try { + const detail = await get(bookId); + if (!cancelled) setBook(detail); + } finally { + dispatch(LoadingActions.clear()); + } + })(); + return () => { cancelled = true; }; + }, [bookId, dispatch]); + + const submit = async (data: BookFormValues) => { + dispatch(LoadingActions.start({ key: 'save' })); + try { + const payload = { + authorId: data.authorId, + name: data.name, + type: Number(data.type), + publishDate: data.publishDate ? new Date(data.publishDate).toISOString() : new Date().toISOString(), + price: Number(data.price), + }; + if (bookId) await update(payload, bookId); + else await create(payload); + navigation.goBack(); + } finally { + dispatch(LoadingActions.clear()); } - }, [authorId]); + }; - return ; + return ; } -CreateUpdateAuthorScreen.propTypes = { - startLoading: PropTypes.func.isRequired, - clearLoading: PropTypes.func.isRequired, -}; - -export default connectToRedux({ - component: CreateUpdateAuthorScreen, - stateProps: (state: any) => ({ loading: createLoadingSelector()(state) }), - dispatchProps: { - startLoading: LoadingActions.start, - clearLoading: LoadingActions.clear, - }, -}); +export default CreateUpdateBookScreen; ``` -Create a `CreateUpdateAuthorForm.tsx` file under the `./src/screens/BookStore/Authors/CreateUpdateAuthor` folder and add the code below to it. +- `LoadingActions.start({ key })` and `LoadingActions.clear()` drive the global `` overlay rendered in `AppContent.tsx` via the Redux loading reducer. No extra wiring is needed in this screen. +- `getAuthorLookup` lives in `BookAPI.ts` (we added it earlier). It returns `{ items: [{ id, name }] }`, which the form turns into a dropdown. -```tsx -import { useRef, useState } from 'react'; -import { Platform, KeyboardAvoidingView, StyleSheet, View, ScrollView } from 'react-native'; +### CreateUpdateBookForm -import { useFormik } from 'formik'; -import i18n from 'i18n-js'; -import PropTypes from 'prop-types'; +The form is a Formik form. We keep `react-native-paper`'s `TextInput` for the input fields (the only Paper component the template still uses) and rely on our new `AbpSelect` for the type and author pickers, plus `DateTimePicker` for the publish date. + +```tsx +// ./src/screens/BookStore/Books/CreateUpdateBook/CreateUpdateBookForm.tsx import * as Yup from 'yup'; -import { Divider, Portal, TextInput, Text, Button, Modal } from 'react-native-paper'; +import { useContext, useMemo, useState } from 'react'; +import { View, Text, ScrollView, KeyboardAvoidingView, Platform, Pressable, Modal } from 'react-native'; +import { useFormik } from 'formik'; +import { TextInput } from 'react-native-paper'; import DateTimePicker from '@react-native-community/datetimepicker'; import { useThemeColors } from '../../../../hooks'; -import { FormButtons, ValidationMessage } from '../../../../components'; - -const validations = { - name: Yup.string().required('AbpValidation::ThisFieldIsRequired.'), - birthDate: Yup.string().nullable().required('AbpValidation::ThisFieldIsRequired.'), -}; - -const props = { - underlineStyle: { backgroundColor: 'transparent' }, - underlineColor: '#333333bf', -}; +import { LocalizationContext } from '../../../../contexts/LocalizationContext'; +import { AbpSelect, FormButtons, ValidationMessage } from '../../../../components'; +import type { AbpSelectItem } from '../../../../components'; + +export interface BookFormValues { + authorId: string; + authorName: string; + name: string; + type: string; + typeDisplayName: string; + publishDate: Date | null; + price: string; +} -function CreateUpdateAuthorForm({ submit, author = null }) { - const { primaryContainer, background, onBackground } = useThemeColors(); +interface CreateUpdateBookFormProps { + submit: (values: BookFormValues) => Promise | void; + book?: any | null; + authors: AbpSelectItem[]; +} - const [birthDateVisible, setPublishDateVisible] = useState(false); +const validationSchema = Yup.object().shape({ + name: Yup.string().required('AbpValidation::ThisFieldIsRequired'), + price: Yup.number().typeError('AbpValidation::ThisFieldIsRequired').required('AbpValidation::ThisFieldIsRequired'), + type: Yup.string().required('AbpValidation::ThisFieldIsRequired'), + authorId: Yup.string().required('AbpValidation::ThisFieldIsRequired'), + publishDate: Yup.date().typeError('AbpValidation::ThisFieldIsRequired').required('AbpValidation::ThisFieldIsRequired').nullable(), +}); - const nameRef = useRef(null); - const birthDateRef = useRef(null); - const shortBioRef = useRef(null); +const formatDate = (value: Date | null) => (value ? new Date(value).toLocaleDateString() : ''); - const inputStyle = { ...styles.input, backgroundColor: primaryContainer }; +function CreateUpdateBookForm({ submit, book, authors }: CreateUpdateBookFormProps) { + const { t } = useContext(LocalizationContext); + const { primaryContainer, accentColor, headerBg } = useThemeColors(); - const onSubmit = (values: any) => { - if (!authorForm.isValid) { - return; - } + const [typeModalVisible, setTypeModalVisible] = useState(false); + const [authorModalVisible, setAuthorModalVisible] = useState(false); + const [dateModalVisible, setDateModalVisible] = useState(false); + const [tempDate, setTempDate] = useState(new Date()); - submit({ ...values }); - }; + const bookTypes = useMemo( + () => Array.from({ length: 8 }, (_, i) => ({ + id: String(i + 1), + displayName: t(`BookStore::Enum:BookType:${i + 1}`), + })), + [t], + ); - const authorForm = useFormik({ + const initialValues: BookFormValues = useMemo(() => { + const typeIdStr = book?.type ? String(book.type) : ''; + return { + authorId: book?.authorId ?? '', + authorName: authors.find(a => String(a.id) === String(book?.authorId))?.displayName ?? '', + name: book?.name ?? '', + type: typeIdStr, + typeDisplayName: typeIdStr ? t(`BookStore::Enum:BookType:${typeIdStr}`) : '', + publishDate: book?.publishDate ? new Date(book.publishDate) : null, + price: book?.price !== undefined ? String(book.price) : '', + }; + }, [book, authors, t]); + + const form = useFormik({ enableReinitialize: true, + initialValues, + validateOnChange: false, validateOnBlur: true, - validationSchema: Yup.object().shape({ - ...validations, - }), - initialValues: { - ...author, - name: author?.name || '', - birthDate: (author?.birthDate && new Date(author?.birthDate)) || null, - shortBio: author?.shortBio || '', - }, - onSubmit, + validationSchema, + onSubmit: values => submit(values), }); - const isInvalidControl = (controlName = null) => { - if (!controlName) { - return; - } - - return ( - ((!!authorForm.touched[controlName] && authorForm.submitCount > 0) || - authorForm.submitCount > 0) && - !!authorForm.errors[controlName] - ); - }; - - const onChange = (event: any, selectedDate: any) => { - if (!selectedDate) { - return; - } - - setPublishDateVisible(false); + const showError = (field: keyof BookFormValues) => + (form.submitCount > 0 || !!form.touched[field]) && !!form.errors[field]; - if (event && event.type !== 'dismissed') { - authorForm.setFieldValue('birthDate', selectedDate, true); - } - }; + const renderError = (field: keyof BookFormValues) => + showError(field) ? {form.errors[field] as string} : null; return ( - - {birthDateVisible && ( - - )} - - - - + + + + {/* Name */} + birthDateRef.current.focus()} - returnKeyType="next" - onChangeText={authorForm.handleChange('name')} - onBlur={authorForm.handleBlur('name')} - value={authorForm.values.name} - autoCapitalize="none" - label={i18n.t('BookStore::Name')} - style={inputStyle} - {...props} + mode="outlined" + label={t('BookStore::Name')} + value={form.values.name} + onChangeText={form.handleChange('name')} + onBlur={form.handleBlur('name')} + error={showError('name')} + autoCapitalize="sentences" + style={{ backgroundColor: primaryContainer }} /> - {isInvalidControl('name') && ( - {authorForm.errors.name as string} - )} + {renderError('name')} + + + {/* Author dropdown — populated in the "Adding Author Relation to Book" section */} + + setAuthorModalVisible(true)}> + + } + style={{ backgroundColor: primaryContainer }} + /> + + + {renderError('authorId')} + + + {/* Type dropdown */} + + setTypeModalVisible(true)}> + + } + style={{ backgroundColor: primaryContainer }} + /> + + + {renderError('type')} + + + {/* Publish date picker */} + + { + setTempDate(form.values.publishDate ?? new Date()); + setDateModalVisible(true); + }}> + + } + style={{ backgroundColor: primaryContainer }} + /> + + + {renderError('publishDate')} - + {/* Price */} + shortBioRef.current.focus()} - right={ - setPublishDateVisible(true)} icon="calendar" /> - } - style={inputStyle} - editable={false} - value={authorForm.values.birthDate?.toLocaleDateString()} - {...props} + mode="outlined" + label={t('BookStore::Price')} + value={form.values.price} + onChangeText={form.handleChange('price')} + onBlur={form.handleBlur('price')} + error={showError('price')} + keyboardType="decimal-pad" + style={{ backgroundColor: primaryContainer }} /> - {isInvalidControl('birthDate') && ( - {authorForm.errors.birthDate as string} - )} + {renderError('price')} + - - - - {i18n.t('BookStore::BirthDate')} + + form.handleSubmit()} isSubmitDisabled={form.isSubmitting} /> + + + + {/* Type modal */} + setTypeModalVisible(false)} + setSelectedItem={(id: any) => { + const idStr = String(id ?? ''); + form.setFieldValue('type', idStr, true); + form.setFieldValue('typeDisplayName', + bookTypes.find(item => item.id === idStr)?.displayName ?? '', false); + }} + /> + + {/* Author modal */} + setAuthorModalVisible(false)} + setSelectedItem={(id: any) => { + const idStr = String(id ?? ''); + form.setFieldValue('authorId', idStr, true); + form.setFieldValue('authorName', + authors.find(item => String(item.id) === idStr)?.displayName ?? '', false); + }} + /> + + {/* Publish date modal */} + setDateModalVisible(false)}> + setDateModalVisible(false)} className="flex-1 bg-black/50 items-center justify-center px-6"> + {}} className="w-full max-w-md bg-card dark:bg-card-dark rounded-2xl border border-card-border dark:border-card-border-dark shadow-lg overflow-hidden"> + + + {t('BookStore::PublishDate')} - + + { + if (Platform.OS !== 'ios') { + setDateModalVisible(false); + if (event?.type !== 'dismissed' && selectedDate) { + form.setFieldValue('publishDate', selectedDate, true); + } + } else if (selectedDate) { + setTempDate(selectedDate); + } + }} maximumDate={new Date()} - textColor={onBackground} /> - - - + + {Platform.OS === 'ios' ? ( + + setDateModalVisible(false)} className="px-4 py-2 rounded-md"> + {t('AbpUi::Cancel')} + + { + form.setFieldValue('publishDate', tempDate, true); + setDateModalVisible(false); + }} + className="px-4 py-2 rounded-md bg-accent dark:bg-accent-dark"> + + {t('AbpUi::Ok')} + + - - - - - authorForm.handleSubmit()} - returnKeyType="next" - onChangeText={authorForm.handleChange('shortBio')} - onBlur={authorForm.handleBlur('shortBio')} - value={authorForm.values.shortBio} - autoCapitalize="none" - label={i18n.t('BookStore::ShortBio')} - style={inputStyle} - {...props} - /> - - - - - - + ) : null} + + + + ); } -const styles = StyleSheet.create({ - inputContainer: { - margin: 8, - marginLeft: 16, - marginRight: 16, - }, - input: { - borderRadius: 8, - borderTopLeftRadius: 8, - borderTopRightRadius: 8, - }, - button: { - marginLeft: 16, - marginRight: 16, - }, - divider: { - marginBottom: 16, - }, - modalButtons: { - flexDirection: 'row', - justifyContent: 'space-between', - marginTop: 20, - paddingHorizontal: 8, - }, - dateModal: { - padding: 20, - margin: 20, - borderRadius: 12, - elevation: 5, - shadowColor: '#000', - shadowOffset: { - width: 0, - height: 2, - }, - shadowOpacity: 0.25, - shadowRadius: 3.84, - }, - modalTitle: { - textAlign: 'center', - marginBottom: 16, - fontWeight: '600', - }, -}); +export default CreateUpdateBookForm; +``` -CreateUpdateAuthorForm.propTypes = { - author: PropTypes.object, - submit: PropTypes.func.isRequired, -}; +- The Android date picker is dismissed automatically once the user picks a date. On iOS we keep the date in `tempDate` and apply it only when the user taps **OK**, so the spinner feels natural. +- `AbpSelect` is fed both for the **Type** dropdown (8 enum entries from the localization namespace) and for the **Author** dropdown (filled from the `authors` prop forwarded by the screen). -export default CreateUpdateAuthorForm; -``` + +![Create New Book](../../../images/create-book.png) -![Author List](../../../images/author-list.png) +## Updating a Book -![Author Create Page](../../../images/create-author.png) +There is no separate "edit" form. `CreateUpdateBookScreen` already accepts a `bookId` route param: when it is set, the screen calls `BookAPI.get(bookId)` and forwards the result as the `book` prop. The form picks the existing values up via `enableReinitialize: true`. The corresponding navigation call in `BooksScreen` passes the id when the user taps **Edit** in the action sheet: -![Author List With Options](../../../images/author-list-with-options.png) +```ts +navigation.navigate('CreateUpdateBook', { bookId: item.id }); +``` -![Author Update Page](../../../images/update-author.png) +When the form submits, the screen branches between `update(payload, bookId)` and `create(payload)` based on whether `bookId` is set. -![Author Delete Alert](../../../images/delete-author-alert.png) + +![Update Book Page](../../../images/update-book.png) -## Add `Author` Relation To Book +## Deleting a Book -Update BookAPI proxy file and include `getAuthorLookup` method +The action-sheet handler in `BooksScreen` already implements deletion: ```ts -import api from "./API"; +const confirmDelete = (item: BookListItem) => { + Alert.alert(t('AbpUi::AreYouSure'), t('BookStore::AreYouSureToDelete'), [ + { text: t('AbpUi::Cancel'), style: 'cancel' }, + { + text: t('AbpUi::Ok'), + style: 'destructive', + onPress: async () => { + await remove(item.id); + setRefresh(prev => prev + 1); + }, + }, + ]); +}; +``` -export const getList = () => api.get("/api/app/book").then(({ data }) => data); +Incrementing `refresh` causes `DataList` to re-fetch from page zero, so the deleted row disappears as soon as the API call returns. -//Add this -export const getAuthorLookup = () => - api.get("/api/app/book/author-lookup").then(({ data }) => data); -//Add this + +![Delete Book Alert](../../../images/delete-book-alert.png) -export const get = (id) => - api.get(`/api/app/book/${id}`).then(({ data }) => data); +## Authorization -export const create = (input) => - api.post("/api/app/book", input).then(({ data }) => data); +We gate the UI in four places, all driven by `useSelector(appConfigSelector)?.auth?.grantedPolicies`: -export const update = (input, id) => - api.put(`/api/app/book/${id}`, input).then(({ data }) => data); +1. **The Bottom Tab itself.** In `BottomTabNavigator.tsx` we render the `BookStoreTab` only when `BookStore.Books` or `BookStore.Authors` is granted (already shown above). +2. **The Books / Authors tab strip.** `BookStoreScreen.tsx` builds its `tabs` array conditionally so a user with only `BookStore.Books` doesn't see an empty Authors tab. +3. **The "+ New Book" button.** Inside `BooksScreen.tsx` the FAB is rendered only when `BookStore.Books.Create` is granted. +4. **Edit / Delete entries in the action sheet.** Same screen — each entry is added based on `BookStore.Books.Edit` and `BookStore.Books.Delete` respectively. -export const remove = (id) => - api.delete(`/api/app/book/${id}`).then(({ data }) => data); -``` +The same four-layer pattern applies to the Authors tab, using `BookStore.Authors.*` keys. -### Add `AuthorName` to the Book List +## Author Section -Open `BooksScreen.tsx` file under the `./src/screens/BookStore/Books` and update code below. +### Author API Proxy -```tsx -//Improts +```ts +// ./src/api/AuthorAPI.ts +import api from './API'; -function BooksScreen({ navigation }) { - //Other codes.. +export const getList = (params: { maxResultCount?: number; skipCount?: number; sorting?: string; filter?: string } = {}) => + api.get('/api/app/author', { params }).then(({ data }) => data); - return ( - //Other codes - ( - ( - openContextMenu(item)} - /> - )} - /> - )} - /> - //Other codes - ); -} +export const get = (id: string) => + api.get(`/api/app/author/${id}`).then(({ data }) => data); + +export const create = (input: any) => + api.post('/api/app/author', input).then(({ data }) => data); + +export const update = (input: any, id: string) => + api.put(`/api/app/author/${id}`, input).then(({ data }) => data); + +export const remove = (id: string) => + api.delete(`/api/app/author/${id}`).then(({ data }) => data); ``` -- `item.authorName` placed beside book type in the book list. +### AuthorsScreen -### Pass authors to the `CreateUpdateBookForm` +The list mirrors `BooksScreen` — same `DataList` + action sheet + FAB pattern, with `BookStore.Authors.*` permissions and `CreateUpdateAuthor` as the navigation target. ```tsx -import { - getAuthorLookup, //Add this line - get, - create, - update, -} from "../../../../api/BookAPI"; -import CreateUpdateBookForm from "./CreateUpdateBookForm"; - -function CreateUpdateBookScreen({ - navigation, - route, - startLoading, - clearLoading, -}) { - //Add this variable - const [authors, setAuthors] = useState([]); - - //Fetch authors from author-lookup endpoint - useEffect(() => { - getAuthorLookup().then(({ items } = {}) => setAuthors(items)); - }, []); - - //Pass author list to Form - return ; -} -//Other codes.. +// ./src/screens/BookStore/Authors/AuthorsScreen.tsx +// (Same shape as BooksScreen — replace the imports of BookAPI with AuthorAPI, +// swap BookStore.Books.* permissions with BookStore.Authors.*, and route +// navigation calls to 'CreateUpdateAuthor' with { authorId } instead of { bookId }.) ``` -- We'll define `authors` prop in the `CreateUpdateBookForm` component and it will be used for Authors dropdown. -- In the useEffect function we'll fetch authors from the server and set `authors` variable. +The full source ships with the sample app under the path above. -### Add `authorId` field to Book Form +### CreateUpdateAuthor -```tsx -const validations = { - authorId: Yup.string() - .nullable() - .required("AbpValidation::ThisFieldIsRequired."), - //Other validators -}; +The screen pair is simpler than for books — there is no author lookup and no enum dropdown. Just a name field, a birth-date picker, and a multi-line short-bio field. -//Add `authors` parameter -function CreateUpdateBookForm({ submit, book = null, authors = [] }) { - //Add this variable for authors list - const [authorSelectVisible, setAuthorSelectVisible] = useState(false); +```tsx +// ./src/screens/BookStore/Authors/CreateUpdateAuthor/CreateUpdateAuthorScreen.tsx +import { useEffect, useState } from 'react'; +import { useDispatch } from 'react-redux'; - const authorIdRef = useRef(); //Add this line +import { get, create, update } from '../../../../api/AuthorAPI'; +import LoadingActions from '../../../../store/actions/LoadingActions'; +import type { CreateUpdateAuthorScreenProps } from '../../../../navigators/types'; +import CreateUpdateAuthorForm, { type AuthorFormValues } from './CreateUpdateAuthorForm'; - //Update form - const bookForm = useFormik({ - enableReinitialize: true, - validateOnBlur: true, - validationSchema: Yup.object().shape({ - ...validations, - }), - initialValues: { - //Add these - authorId: book?.authorId || "", - author: authors.find((f) => f.id === book?.authorId)?.name || "", - //Add these - }, - onSubmit, - }); +function CreateUpdateAuthorScreen({ navigation, route }: CreateUpdateAuthorScreenProps) { + const { authorId } = route.params || {}; + const dispatch = useDispatch(); - //Other codes.. + const [author, setAuthor] = useState(null); - //Add `AbpSelect` component and TextInput for authors - return ( - - ({ id, displayName: name }))} - hasDefualtItem={true} - hideModalFn={() => setAuthorSelectVisible(false)} - selectedItem={bookForm.values.authorId} - setSelectedItem={(id) => { - bookForm.setFieldValue("authorId", id, true); - bookForm.setFieldValue( - "author", - authors.find((f) => f.id === id)?.name || null, - false - ); - }} - /> + useEffect(() => { + if (!authorId) return; + let cancelled = false; + (async () => { + dispatch(LoadingActions.start({ key: 'fetchAuthorDetail' })); + try { + const detail = await get(authorId); + if (!cancelled) setAuthor(detail); + } finally { + dispatch(LoadingActions.clear()); + } + })(); + return () => { cancelled = true; }; + }, [authorId, dispatch]); + + const submit = async (data: AuthorFormValues) => { + dispatch(LoadingActions.start({ key: 'save' })); + try { + const payload = { + name: data.name, + birthDate: data.birthDate ? new Date(data.birthDate).toISOString() : new Date().toISOString(), + shortBio: data.shortBio?.trim() ? data.shortBio : null, + }; + if (authorId) await update(payload, authorId); + else await create(payload); + navigation.goBack(); + } finally { + dispatch(LoadingActions.clear()); + } + }; - - - - setAuthorSelectVisible(true)} - icon="menu-down" - /> - } - style={inputStyle} - editable={false} - value={bookForm.values.author} - {...props} - /> - {isInvalidControl("authorId") && ( - {bookForm.errors.authorId} - )} - - - - - ); + return ; } -CreateUpdateBookForm.propTypes = { - authors: PropTypes.array.isRequired, //Include this -}; -export default CreateUpdateBookForm; +export default CreateUpdateAuthorScreen; ``` -- Create authors dropdown input with `AbpSelect` component. -- Display selected author in the `TextInput` +The form (`CreateUpdateAuthorForm.tsx`) is a stripped-down version of the book form — only the name, birth-date and short-bio fields, no `AbpSelect`. Refer to the sample source for the full file; it follows the exact same NativeWind layout used in `CreateUpdateBookForm.tsx`. -![Book List with Author](../../../images/book-list-with-author.png) + +![Author Create Page](../../../images/create-author.png) + +## Adding the Author Relation to Books + +This is the part that ties everything together: the book list shows the author name beside the book type, and the create/edit form lets the user choose the author from a dropdown filled by the `getAuthorLookup` endpoint. + +Both pieces are already wired in the code we wrote earlier: -![Author Input in Book Form](../../../images/author-input-in-book-form.png) +- `BooksScreen.tsx` renders `${item.authorName} · ${t('BookStore::Enum:BookType:${item.type}')}` for each row. The backend's `BookAppService.GetListAsync` joins the `Authors` collection so `authorName` is part of every item. +- `CreateUpdateBookScreen.tsx` calls `getAuthorLookup` on mount and passes the result to the form as the `authors` prop. The form's `Author` field is an `AbpSelect` that reads from that prop and writes the chosen id (and display name) back to Formik state. +If you want to verify the relation visually: + +1. Start the backend (`Acme.BookStore.HttpApi.Host`). +2. Run the React Native app (`npm start` in `react-native/`). +3. Log in as `admin` / `1q2w3E*`. The seeder created three sample authors and six sample books. +4. Open the **Book Store** tab. The book list shows entries like `The Hobbit · J.R.R. Tolkien · Fantastic`. +5. Tap **+ New Book** and confirm the **Author** dropdown lists the three seeded authors. + + +![Book List with Author](../../../images/book-list-with-author.png) ![Authors in Book Form](../../../images/authors-in-book-form.png) -That is all. Just run the application and try to create or edit an author. +## Where to go next + +- The drawer-only template variant (`navigation_type = "drawer"`) follows the same flow — replace the `BottomTabNavigator` step with the equivalent `Drawer.Screen` in `DrawerNavigator.tsx`. +- Localization for the `BookStore::*` namespace is in `react-native/src/locales/{en,tr}.json` and the matching backend resource in `src/Acme.BookStore.Domain.Shared/Localization/BookStore/`. Adding more languages is a matter of registering them in `LocalizationService.ts` and creating the corresponding JSON files. +- The full sample is the source of truth: any time the snippets here look incomplete, open the same path inside the downloaded `bookstore-react-native-mongodb` solution.