('');
+ const router = useIonRouter();
+ const { mutate: createUser } = useCreateUser();
+ const { setProgress } = useProgress();
+ const { createToast } = useToasts();
+
+ const onCancel = () => {
+ router.goBack();
+ };
+
+ return (
+
+
+
+
+ {error && (
+
+ )}
+
+ {
+ setProgress(true);
+ setError('');
+ createUser(
+ { user: values },
+ {
+ onSuccess: (user) => {
+ setProgress(false);
+ setSubmitting(false);
+ createToast({
+ buttons: [DismissButton],
+ duration: 5000,
+ message: `${user.name} created`,
+ });
+ router.push(`/tabs/users/${user.id}`, 'forward', 'replace');
+ },
+ onError(error) {
+ setProgress(false);
+ setError(error.message);
+ setSubmitting(false);
+ },
+ },
+ );
+ }}
+ testid={`${testid}-form`}
+ />
+
+
+
+
+ );
+};
+
+export default UserAdd;
diff --git a/src/pages/Users/components/UserAdd/UserAddFab.scss b/src/pages/Users/components/UserAdd/UserAddFab.scss
new file mode 100644
index 0000000..187c6a0
--- /dev/null
+++ b/src/pages/Users/components/UserAdd/UserAddFab.scss
@@ -0,0 +1,5 @@
+.fab-user-add {
+ .icon {
+ font-size: 1.125rem;
+ }
+}
diff --git a/src/pages/Users/components/UserAdd/UserAddFab.tsx b/src/pages/Users/components/UserAdd/UserAddFab.tsx
new file mode 100644
index 0000000..4900d40
--- /dev/null
+++ b/src/pages/Users/components/UserAdd/UserAddFab.tsx
@@ -0,0 +1,35 @@
+import { IonFab, IonFabButton } from '@ionic/react';
+import classNames from 'classnames';
+
+import './UserAddFab.scss';
+import { BaseComponentProps } from 'common/components/types';
+import Icon, { IconName } from 'common/components/Icon/Icon';
+
+/**
+ * Properties for the `UserAddFab` component.
+ */
+interface UserAddFabProps extends BaseComponentProps {}
+
+/**
+ * The `UserAddFab` renders an Ionic Floating Action Button, or FAB.
+ * The button navigates to the create new `User` form when clicked.
+ * @param {UserAddFabProps} props - Component properties.
+ * @returns {JSX.Element} JSX
+ */
+const UserAddFab = ({ className, testid = 'fab-user-add' }: UserAddFabProps): JSX.Element => {
+ return (
+
+
+
+
+
+ );
+};
+
+export default UserAddFab;
diff --git a/src/pages/Users/components/UserAdd/UserAddPage.tsx b/src/pages/Users/components/UserAdd/UserAddPage.tsx
new file mode 100644
index 0000000..ee45cc3
--- /dev/null
+++ b/src/pages/Users/components/UserAdd/UserAddPage.tsx
@@ -0,0 +1,35 @@
+import { IonContent, IonPage } from '@ionic/react';
+
+import { PropsWithTestId } from 'common/components/types';
+import ProgressProvider from 'common/providers/ProgressProvider';
+import PageHeader from 'common/components/Content/PageHeader';
+import Header from 'common/components/Header/Header';
+import Container from 'common/components/Content/Container';
+import UserAdd from './UserAdd';
+
+/**
+ * The `UserAddPage` component renders a page layout containing a form to
+ * create new `User`s.
+ * @param {PropsWithTestId} props - Component properties.
+ * @returns {JSX.Element} JSX
+ */
+const UserAddPage = ({ testid = 'page-user-add' }: PropsWithTestId): JSX.Element => {
+ return (
+
+
+
+
+
+
+ Add User
+
+
+
+
+
+
+
+ );
+};
+
+export default UserAddPage;
diff --git a/src/pages/Users/components/UserAdd/__tests__/UserAdd.test.tsx b/src/pages/Users/components/UserAdd/__tests__/UserAdd.test.tsx
new file mode 100644
index 0000000..05685d5
--- /dev/null
+++ b/src/pages/Users/components/UserAdd/__tests__/UserAdd.test.tsx
@@ -0,0 +1,16 @@
+import { describe, expect, it } from 'vitest';
+
+import { render, screen } from 'test/test-utils';
+
+import UserAdd from '../UserAdd';
+
+describe('UserAdd', () => {
+ it('should render successfully', async () => {
+ // ARRANGE
+ render();
+ await screen.findByTestId('user-add');
+
+ // ASSERT
+ expect(screen.getByTestId('user-add')).toBeDefined();
+ });
+});
diff --git a/src/pages/Users/components/UserAdd/__tests__/UserAddFab.test.tsx b/src/pages/Users/components/UserAdd/__tests__/UserAddFab.test.tsx
new file mode 100644
index 0000000..61a2740
--- /dev/null
+++ b/src/pages/Users/components/UserAdd/__tests__/UserAddFab.test.tsx
@@ -0,0 +1,16 @@
+import { describe, expect, it } from 'vitest';
+
+import { render, screen } from 'test/test-utils';
+
+import UserAddFab from '../UserAddFab';
+
+describe('UserAddFab', () => {
+ it('should render successfully', async () => {
+ // ARRANGE
+ render();
+ await screen.findByTestId('fab-user-add');
+
+ // ASSERT
+ expect(screen.getByTestId('fab-user-add')).toBeDefined();
+ });
+});
diff --git a/src/pages/Users/components/UserAdd/__tests__/UserAddPage.test.tsx b/src/pages/Users/components/UserAdd/__tests__/UserAddPage.test.tsx
new file mode 100644
index 0000000..331b560
--- /dev/null
+++ b/src/pages/Users/components/UserAdd/__tests__/UserAddPage.test.tsx
@@ -0,0 +1,16 @@
+import { describe, expect, it } from 'vitest';
+
+import { render, screen } from 'test/test-utils';
+
+import UserAddPage from '../UserAddPage';
+
+describe('UserAddPage', () => {
+ it('should render successfully', async () => {
+ // ARRANGE
+ render();
+ await screen.findByTestId('page-user-add');
+
+ // ASSERT
+ expect(screen.getByTestId('page-user-add')).toBeDefined();
+ });
+});
diff --git a/src/pages/Users/components/UserEdit/UserEdit.tsx b/src/pages/Users/components/UserEdit/UserEdit.tsx
new file mode 100644
index 0000000..9869d2e
--- /dev/null
+++ b/src/pages/Users/components/UserEdit/UserEdit.tsx
@@ -0,0 +1,91 @@
+import { useState } from 'react';
+import { IonCol, IonGrid, IonRow, useIonRouter } from '@ionic/react';
+import classNames from 'classnames';
+
+import { BaseComponentProps } from 'common/components/types';
+import { User } from 'common/models/user';
+import { useUpdateUser } from 'pages/Users/api/useUpdateUser';
+import { useProgress } from 'common/hooks/useProgress';
+import { useToasts } from 'common/hooks/useToasts';
+import { DismissButton } from 'common/components/Toast/Toast';
+import ErrorCard from 'common/components/Card/ErrorCard';
+import UserForm from '../UserForm/UserForm';
+
+/**
+ * Properties for the `UserEdit` component.
+ * @see {@link BaseComponentProps}
+ */
+interface UserEditProps extends BaseComponentProps {
+ user: User;
+}
+
+/**
+ * The `UserEdit` component renders a Formik form for editing a `User`.
+ * @param {UserEditProps} props - Component properties.
+ * @returns {JSX.Element} JSX
+ */
+const UserEdit = ({ className, user, testid = 'user-edit' }: UserEditProps): JSX.Element => {
+ const router = useIonRouter();
+ const [error, setError] = useState('');
+ const { mutate: updateUser } = useUpdateUser();
+ const { createToast } = useToasts();
+ const { setProgress } = useProgress();
+
+ const onCancel = () => {
+ router.goBack();
+ };
+
+ return (
+
+
+
+
+ {error && (
+
+ )}
+
+ {
+ setProgress(true);
+ setError('');
+ updateUser(
+ { user: { ...user, ...values } },
+ {
+ onSuccess: (user) => {
+ setProgress(false);
+ setSubmitting(false);
+ createToast({
+ buttons: [DismissButton],
+ duration: 5000,
+ message: `${user.name} updated`,
+ });
+ if (router.canGoBack()) {
+ router.goBack();
+ } else {
+ router.push(`/tabs/users/${user.id}`, 'back', 'replace');
+ }
+ },
+ onError(error) {
+ setProgress(false);
+ setError(error.message);
+ setSubmitting(false);
+ },
+ },
+ );
+ }}
+ testid={`${testid}-form`}
+ />
+
+
+
+
+ );
+};
+
+export default UserEdit;
diff --git a/src/pages/Users/components/UserEdit/UserEditForm.scss b/src/pages/Users/components/UserEdit/UserEditForm.scss
deleted file mode 100644
index 2339a4a..0000000
--- a/src/pages/Users/components/UserEdit/UserEditForm.scss
+++ /dev/null
@@ -1,32 +0,0 @@
-.form-user-edit {
- max-width: 45rem;
-
- margin: 1rem 0;
-
- ion-input {
- margin-bottom: 0.5rem;
- }
-
- .buttons {
- display: flex;
-
- ion-button {
- width: 100%;
- }
-
- .spinner-button-save {
- ion-spinner {
- height: 16px;
- width: 16px;
- }
- }
- }
-
- .row-message {
- margin: 1rem 0;
-
- &.row-card .wrapper {
- width: 100% !important;
- }
- }
-}
diff --git a/src/pages/Users/components/UserEdit/UserEditForm.tsx b/src/pages/Users/components/UserEdit/UserEditForm.tsx
deleted file mode 100644
index 8fa1022..0000000
--- a/src/pages/Users/components/UserEdit/UserEditForm.tsx
+++ /dev/null
@@ -1,184 +0,0 @@
-import { useState } from 'react';
-import { IonButton, useIonRouter } from '@ionic/react';
-import { Form, Formik } from 'formik';
-import { object, string } from 'yup';
-import classNames from 'classnames';
-
-import './UserEditForm.scss';
-import { BaseComponentProps } from 'common/components/types';
-import { User } from 'common/models/user';
-import { useUpdateUser } from 'pages/Users/api/useUpdateUser';
-import { useToasts } from 'common/hooks/useToasts';
-import { DismissButton } from 'common/components/Toast/Toast';
-import Input from 'common/components/Input/Input';
-import CardRow from 'common/components/Card/CardRow';
-import ErrorCard from 'common/components/Card/ErrorCard';
-import LoaderSpinner from 'common/components/Loader/LoaderSpinner';
-import Icon, { IconName } from 'common/components/Icon/Icon';
-import HeaderRow from 'common/components/Text/HeaderRow';
-
-/**
- * Properties for the `UserEditForm` component.
- * @see {@link BaseComponentProps}
- */
-interface UserEditFormProps extends BaseComponentProps {
- user: User;
-}
-
-/**
- * User edit form values.
- * @param {User} user - A `User` object.
- */
-interface UserEditFormValues {
- user: User;
-}
-
-/**
- * User edit form validation schema.
- */
-const validationSchema = object({
- user: object({
- name: string().required('Required. '),
- username: string()
- .required('Required. ')
- .min(8, 'Must be at least 8 characters. ')
- .max(30, 'Must be at most 30 characters. '),
- email: string().required('Required. ').email('Must be an email address. '),
- phone: string().required('Required. '),
- website: string().url('Must be a URL. '),
- }),
-});
-
-/**
- * The `UserEditForm` component renders a Formik form for editing a `User`.
- * @param {UserEditFormProps} props - Component properties.
- * @returns {JSX.Element} JSX
- */
-const UserEditForm = ({
- className,
- user,
- testid = 'form-user-edit',
-}: UserEditFormProps): JSX.Element => {
- const router = useIonRouter();
- const [error, setError] = useState('');
- const { mutate: updateUser, isPending } = useUpdateUser();
- const { createToast } = useToasts();
-
- const onCancel = () => {
- router.goBack();
- };
-
- return (
-
- {error && (
-
-
-
- )}
-
- enableReinitialize={true}
- initialValues={{ user: user }}
- onSubmit={(values, { setSubmitting }) => {
- setError('');
- updateUser(
- { user: { ...user, ...values.user } },
- {
- onSuccess: (user) => {
- setSubmitting(false);
- createToast({
- buttons: [DismissButton],
- duration: 5000,
- message: `${user.name} updated`,
- });
- if (router.canGoBack()) {
- router.goBack();
- } else {
- router.push(`/tabs/users/${user.id}`, 'back', 'replace');
- }
- },
- onError(error) {
- setError(error.message);
- setSubmitting(false);
- },
- },
- );
- }}
- validationSchema={validationSchema}
- >
- {({ dirty, isSubmitting }) => (
-
- )}
-
-
- );
-};
-
-export default UserEditForm;
diff --git a/src/pages/Users/components/UserEdit/UserEditPage.tsx b/src/pages/Users/components/UserEdit/UserEditPage.tsx
index 31930a4..a6176b6 100644
--- a/src/pages/Users/components/UserEdit/UserEditPage.tsx
+++ b/src/pages/Users/components/UserEdit/UserEditPage.tsx
@@ -8,7 +8,8 @@ import Container from 'common/components/Content/Container';
import PageHeader from 'common/components/Content/PageHeader';
import Avatar from 'common/components/Icon/Avatar';
import LoaderSkeleton from 'common/components/Loader/LoaderSkeleton';
-import UserEditForm from './UserEditForm';
+import UserEdit from './UserEdit';
+import ProgressProvider from 'common/providers/ProgressProvider';
/**
* Router path parameters for the `UserEditPage`.
@@ -30,29 +31,31 @@ export const UserEditPage = (): JSX.Element => {
return (
-
+
+
-
-
- {user ? (
- <>
-
-
- {user.name}
-
+
+
+ {user ? (
+ <>
+
+
+ {user.name}
+
-
- >
- ) : (
-
- )}
-
-
+
+ >
+ ) : (
+
+ )}
+
+
+
);
};
diff --git a/src/pages/Users/components/UserEdit/__tests__/UserEdit.test.tsx b/src/pages/Users/components/UserEdit/__tests__/UserEdit.test.tsx
new file mode 100644
index 0000000..93846e5
--- /dev/null
+++ b/src/pages/Users/components/UserEdit/__tests__/UserEdit.test.tsx
@@ -0,0 +1,33 @@
+import { describe, expect, it } from 'vitest';
+import userEvent from '@testing-library/user-event';
+
+import { render, screen } from 'test/test-utils';
+import { userFixture1 } from '__fixtures__/users';
+
+import UserEdit from '../UserEdit';
+
+describe('UserEdit', () => {
+ it('should render successfully', async () => {
+ // ARRANGE
+ render();
+ await screen.findByTestId('user-edit');
+
+ // ASSERT
+ expect(screen.getByTestId('user-edit')).toBeDefined();
+ });
+
+ it('should submit form', async () => {
+ // ARRANGE
+ render();
+ await screen.findByTestId('user-edit-form');
+
+ // ACT
+ await userEvent.click(screen.getByTestId('user-edit-form-field-name'));
+ await userEvent.clear(screen.getByLabelText('Name'));
+ await userEvent.type(screen.getByLabelText('Name'), 'test name');
+ await userEvent.click(screen.getByTestId('user-edit-form-button-submit'));
+
+ // ASSERT
+ expect(screen.getByTestId('user-edit-form')).toBeDefined();
+ });
+});
diff --git a/src/pages/Users/components/UserEdit/__tests__/UserEditForm.test.tsx b/src/pages/Users/components/UserEdit/__tests__/UserEditForm.test.tsx
deleted file mode 100644
index a806dfb..0000000
--- a/src/pages/Users/components/UserEdit/__tests__/UserEditForm.test.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-import { describe, expect, it } from 'vitest';
-import userEvent from '@testing-library/user-event';
-
-import { render, screen } from 'test/test-utils';
-import { userFixture1 } from '__fixtures__/users';
-
-import UserEditForm from '../UserEditForm';
-
-describe('UserEditForm', () => {
- it('should render successfully', async () => {
- // ARRANGE
- render();
- await screen.findByTestId('form-user-edit');
-
- // ASSERT
- expect(screen.getByTestId('form-user-edit')).toBeDefined();
- });
-
- it('should submit form', async () => {
- // ARRANGE
- render();
- await screen.findByTestId('form');
-
- // ACT
- await userEvent.click(screen.getByTestId('form-field-name'));
- await userEvent.clear(screen.getByLabelText('Name'));
- await userEvent.type(screen.getByLabelText('Name'), 'test name');
- await userEvent.click(screen.getByTestId('form-button-submit'));
-
- // ASSERT
- expect(screen.getByTestId('form')).toBeDefined();
- });
-});
diff --git a/src/pages/Users/components/UserForm/UserForm.scss b/src/pages/Users/components/UserForm/UserForm.scss
new file mode 100644
index 0000000..dacb2df
--- /dev/null
+++ b/src/pages/Users/components/UserForm/UserForm.scss
@@ -0,0 +1,13 @@
+.form-user {
+ ion-input {
+ margin-bottom: 0.5rem;
+ }
+
+ .buttons {
+ display: flex;
+
+ ion-button {
+ width: 100%;
+ }
+ }
+}
diff --git a/src/pages/Users/components/UserForm/UserForm.tsx b/src/pages/Users/components/UserForm/UserForm.tsx
new file mode 100644
index 0000000..55e2e1e
--- /dev/null
+++ b/src/pages/Users/components/UserForm/UserForm.tsx
@@ -0,0 +1,141 @@
+import { IonButton } from '@ionic/react';
+import { Form, Formik, FormikHelpers } from 'formik';
+import { object, string } from 'yup';
+import classNames from 'classnames';
+
+import './UserForm.scss';
+import { BaseComponentProps } from 'common/components/types';
+import { User } from 'common/models/user';
+import Input from 'common/components/Input/Input';
+
+/**
+ * User form values.
+ * @see {@link User}
+ */
+type UserFormValues = Pick;
+
+/**
+ * Properties for the `UserForm` component.
+ * @param {User} [user] - Optional. User to initialize the form.
+ * @see {@link BaseComponentProps}
+ */
+interface UserFormProps extends BaseComponentProps {
+ onCancel: () => void;
+ onSubmit: (values: UserFormValues, helpers: FormikHelpers) => void;
+ user?: User;
+}
+
+/**
+ * User form validation schema.
+ */
+const validationSchema = object({
+ name: string().required('Required. '),
+ username: string()
+ .required('Required. ')
+ .min(8, 'Must be at least 8 characters. ')
+ .max(30, 'Must be at most 30 characters. '),
+ email: string().required('Required. ').email('Must be an email address. '),
+ phone: string().required('Required. '),
+ website: string().url('Must be a URL. '),
+});
+
+/**
+ * The `UserForm` component renders a Formik form for creating or editing
+ * a `User`.
+ * @param {UserFormProps} props - Component properties.
+ * @returns {JSX.Element} JSX
+ */
+const UserForm = ({
+ className,
+ onCancel,
+ onSubmit,
+ user,
+ testid = 'form-user',
+}: UserFormProps): JSX.Element => {
+ return (
+
+
+ enableReinitialize={true}
+ initialValues={{
+ email: user?.email ?? '',
+ name: user?.name ?? '',
+ phone: user?.phone ?? '',
+ username: user?.username ?? '',
+ website: user?.website ?? '',
+ }}
+ onSubmit={onSubmit}
+ validationSchema={validationSchema}
+ >
+ {({ dirty, isSubmitting }) => (
+
+ )}
+
+
+ );
+};
+
+export default UserForm;
diff --git a/src/pages/Users/components/UserForm/__tests__/UserForm.test.tsx b/src/pages/Users/components/UserForm/__tests__/UserForm.test.tsx
new file mode 100644
index 0000000..367b8bd
--- /dev/null
+++ b/src/pages/Users/components/UserForm/__tests__/UserForm.test.tsx
@@ -0,0 +1,41 @@
+import { describe, expect, it, vi } from 'vitest';
+import userEvent from '@testing-library/user-event';
+
+import { render, screen } from 'test/test-utils';
+import { userFixture1 } from '__fixtures__/users';
+
+import UserForm from '../UserForm';
+
+describe('UserForm', () => {
+ it('should render successfully', async () => {
+ // ARRANGE
+ const mockOnCancel = vi.fn();
+ const mockOnSubmit = vi.fn();
+ render();
+ await screen.findByTestId('form-user');
+
+ // ASSERT
+ expect(screen.getByTestId('form-user')).toBeDefined();
+ });
+
+ it('should submit form', async () => {
+ // ARRANGE
+ const mockOnCancel = vi.fn();
+ const mockOnSubmit = vi.fn();
+ render();
+ await screen.findByTestId('form-user');
+
+ // ACT
+ await userEvent.click(screen.getByTestId('form-user-field-name'));
+ await userEvent.type(screen.getByLabelText('Name'), 'name');
+ await userEvent.type(screen.getByLabelText('Username'), 'username');
+ await userEvent.type(screen.getByLabelText('Email'), 'test@example.com');
+ await userEvent.type(screen.getByLabelText('Phone'), '123-456-7890');
+ await userEvent.type(screen.getByLabelText('Website'), 'https://test.com');
+ await userEvent.click(screen.getByTestId('form-user-button-submit'));
+
+ // ASSERT
+ expect(screen.getByTestId('form-user')).toBeDefined();
+ expect(mockOnSubmit).toHaveBeenCalled();
+ });
+});
diff --git a/src/pages/Users/components/UserList/UserListPage.scss b/src/pages/Users/components/UserList/UserListPage.scss
index ca3c79c..c5f85b2 100644
--- a/src/pages/Users/components/UserList/UserListPage.scss
+++ b/src/pages/Users/components/UserList/UserListPage.scss
@@ -4,6 +4,7 @@
}
.list-user {
- margin: 1rem 0;
+ margin-top: 1rem;
+ margin-bottom: 5rem;
}
}
diff --git a/src/pages/Users/components/UserList/UserListPage.tsx b/src/pages/Users/components/UserList/UserListPage.tsx
index 3c772d2..3ebec20 100644
--- a/src/pages/Users/components/UserList/UserListPage.tsx
+++ b/src/pages/Users/components/UserList/UserListPage.tsx
@@ -1,4 +1,6 @@
import {
+ IonButton,
+ IonButtons,
IonContent,
IonPage,
IonRefresher,
@@ -15,6 +17,8 @@ import PageHeader from 'common/components/Content/PageHeader';
import UserList from './UserList';
import UserGrid from './UserGrid';
import ProgressProvider from 'common/providers/ProgressProvider';
+import UserAddFab from '../UserAdd/UserAddFab';
+import Icon, { IconName } from 'common/components/Icon/Icon';
/**
* The `UserListPage` component renders a list of all `User` objects.
@@ -41,11 +45,22 @@ export const UserListPage = (): JSX.Element => {
- Users
+ Users
+
+
+
+
+
+