Django, InertiaJs and React (Vite) - A guide to get you started Pt. 2

saiforceone

Simon (Sai)

Posted on January 28, 2023

Django, InertiaJs and React (Vite) - A guide to get you started Pt. 2

⚠️ The first part of this tutorial is based on InertiaJs prior to version 1.0. Towards the end of this tutorial, we will tackle upgrading InertiaJs.

This is Part 2 of our Django, InertiaJs & React/Vite tutorial. If you have not looked at Part 1, I would recommend that you check it out here.

ℹ️ To make it easier for you to pick up where we left off or to make sure your project is in working order before we proceed, check out the repo here. Be sure to check the readme before continuing.

In this part of the tutorial series, we are going to be looking at a few things:

  • Integrating TypeScript
  • Installing Material UI (you can use any component library you want)
  • Implementing the contact app
    • Backend
    • models, views
    • Frontend
    • Pages and components
  • Upgrading InertiaJs to 1.0

Let's get to it, shall we?

⚠️ You may see an error running the project on (MacOS) after cloning the repo that looks like zsh: permission denied: ./manage.py, you will need to change the permission on that file by using chmod.

# Review the contents of any file that you are going to make executable before making it executable
chmod +x manage.py
Enter fullscreen mode Exit fullscreen mode

The manage.py file should look like what is below

#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys


def main():
    """Run administrative tasks."""
    os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'contact_manager.settings')
    try:
        from django.core.management import execute_from_command_line
    except ImportError as exc:
        raise ImportError(
            "Couldn't import Django. Are you sure it's installed and "
            "available on your PYTHONPATH environment variable? Did you "
            "forget to activate a virtual environment?"
        ) from exc
    execute_from_command_line(sys.argv)


if __name__ == '__main__':
    main()
Enter fullscreen mode Exit fullscreen mode

Optional: Installing Prettier (It just makes sense)

Installing Prettier is optional, but still a pretty good idea 😏. So let's do that and set it up before proceeding.

  • Install it using the command below
npm i -D prettier
Enter fullscreen mode Exit fullscreen mode
  • Create a prettier configuration file .prettierrc.json in the root of the project and copy and paste the content below (minimal prettier configuration)
{
  "semi": true,
  "singleQuote": true,
  "tabWidth": 2,
  "trailingComma": "es5"
}
Enter fullscreen mode Exit fullscreen mode
  • Depending on your editor or IDE, you may need to tell it to use Prettier.

Integrating TypeScript

Now that we have a working project, let's make it even better with TypeScript. Adding TypeScript is going to make our lives easier in the long run, so let's add it now.

  • Create a branch from main, we'll call it part-2-ts-and-forms or anything you want
git checkout -b part-2-integrating-ts-and-forms
Enter fullscreen mode Exit fullscreen mode
  • Install dependencies (as dev dependences) via npm
# install dev dependencies
npm i -D ts-loader typescript @types/react @types/react-dom
Enter fullscreen mode Exit fullscreen mode

dependencies installed

  • Initialise TypeScript with some presets
npx tsc --init
Enter fullscreen mode Exit fullscreen mode

tsc init

  • Replace the contents of the generated tsconfig.json file with what is shown below
{
 "compilerOptions": {
   "target": "ESNext",
   "useDefineForClassFields": true,
   "lib": ["DOM", "DOM.Iterable", "ESNext"],
   "allowJs": false,
   "skipLibCheck": true,
   "esModuleInterop": false,
   "allowSyntheticDefaultImports": true,
   "strict": true,
   "forceConsistentCasingInFileNames": true,
   "module": "ESNext",
   "moduleResolution": "Node",
   "resolveJsonModule": true,
   "isolatedModules": true,
   "noEmit": true,
   "jsx": "react-jsx",
   "types": ["vite/client"],
 },
 "include": ["react-app/src"],
}
Enter fullscreen mode Exit fullscreen mode

package.json contents

  • Don't forget to add the tsconfig.json file to version control (if you're using git)
  • Change the extensions of all our .jsx files to .tsx with the exception of main.jsx
  • Rename react-app/src/pages/Home/index.jsx to end with .tsx
    • Modify our home component to add the necessary interface as shown below
interface HomePageProps {
  contacts: string[];
}

export default function Home({ contacts }: HomePageProps) {
  return (
    <div>
      <h1>Contact List</h1>
      {Array.isArray(contacts) && contacts.length ? (
        <ul>
          {contacts.map((contact) => (
            <li key={contact}>{contact}</li>
          ))}
        </ul>
      ) : (
        <p>No contacts yet...</p>
      )}
    </div>
  );
}

Enter fullscreen mode Exit fullscreen mode

ℹ️ We will replace the contents of this file with something else eventually but, for right now, we don't want TypeScript to complain

  • Rename react-app/src/components/Layout.jsx to end with .tsx
    • Modify the layout component to add the necessary interface as shown below
import React from 'react';
import type { FC } from 'react';

interface LayoutProps {
  children: React.ReactNode;
}

const Layout: FC<LayoutProps> = ({ children }) => {
  return (
    <main>
      <div>{children}</div>
    </main>
  );
};

export default (page: React.ReactNode | React.ReactElement) => (
  <Layout>{page}</Layout>
);

Enter fullscreen mode Exit fullscreen mode
  • Update our main.jsx component as follows
// change this line
const pages = import.meta.glob('./pages/**/*.jsx');
// to
const pages = import.meta.glob('./pages/**/*.tsx');

// change this line
const page = (await pages[`./pages/${name}.jsx`]()).default;
// to
const page = (await pages[`./pages/${name}.tsx`]()).default;
Enter fullscreen mode Exit fullscreen mode

Main.jsx with changes

ℹ️ We will be revisiting the converting of main.jsx to TypeScript in the upgrade to InertiaJs v1.0 section

  • Re-run vite via npm run dev along with the Django dev server via ./manage.py runserver and navigate to http://127.0.0.1:8000 and you shouldn't see any errors in the browser console

Typescript components working without errors

Implementing the Contact Manager

We're now at the point where we can implement our contact manager application. Keep in mind that this is a somewhat-simplified example web application. We will be skipping authentication to keep this part short. (In a future Django/InertiaJs project, we'll build something more complicated complete with auth) We will need have a way to manage contacts and notes for each contact. Let's do some Django things.

  • Models

    • Each model we create will have the these common fields
    • Created At
    • Updated At
    • Model: Contact - For each contact, we will want to keep track of the following items
    • First name
    • Last name
    • Middle Name
    • Email Address
    • Phone Number
  • Create our contact app using django admin as follows

django-admin startapp contact
Enter fullscreen mode Exit fullscreen mode
  • If you're using git, go ahead and add all files from the contact app we just created to git
  • Let's also add our newly created contact app to INSTALLED_APPS so that Django can 'see' it 👀 in settings.py
INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    # django InertiaJS apps
    'django_vite',
    'inertia',
    # our apps
    'contact' # <- this line was added
]
Enter fullscreen mode Exit fullscreen mode
  • Let's define our models in our model file called...well, models.py located at contact/models.py
import datetime

from django.db import models


# Create your models here.

class AppModel(models.Model):
    """
    We are using this as an abstract model so that we can define and reuse common fields across models
    """
    created_at = models.DateTimeField(default=datetime.datetime.utcnow)
    updated_at = models.DateTimeField(blank=True, null=True)

    class Meta:
        abstract = True


class Contact(AppModel):
    first_name = models.CharField(max_length=50)
    middle_name = models.CharField(blank=True, max_length=50, null=True)
    last_name = models.CharField(max_length=50)
    email_address = models.EmailField(max_length=200)
    phone_number = models.CharField(max_length=20)

    def __str__(self):
        return f'({self.id}) {self.first_name} {self.last_name}'


class ContactNote(AppModel):
    contact = models.ForeignKey('Contact', on_delete=models.CASCADE, related_name='ContactNoteOwner')
    content = models.TextField()

    def __str__(self):
        return f'({self.id}) Note for {self.contact.first_name} {self.contact.last_name}'

Enter fullscreen mode Exit fullscreen mode
  • With our models created, we can run our migration (this will get rid of the unapplied migrations warning)
# *nix or Mac OS
./manage.py makemigrations
./manage.py migrate
# windows
manage.py makemigrations
manage.py migrate
Enter fullscreen mode Exit fullscreen mode

Migrations created

Migrations applied

ℹ️ While we have created a ContactNote model. I'll leave that up to you to make use of or I can do a Part 2.5 to this guide which covers that.

Django Admin

Let's detour a bit and set up our Django admin so that we can easily create some test data for our application

  • Create the django super user account by running the command below
./manage.py createsuperuser
Enter fullscreen mode Exit fullscreen mode
  • Fill out the information asked in the prompts. Once done, it should say that the superuser was created successfully.

super user createdℹ️ If you want to see all the commands available, you can just run ./manage.py

  • Let's make sure things are working correctly by running the django dev server and navigating to http://127.0.0.1:8000/admin. You should see something like image below

django admin login

django admin logged in

  • Let's register our models so that they also show up in the Django's admin interface by modifying the file contact/admin.py
from django.contrib import admin
from contact import models # There are other ways to do the model imports

# Register your models here.


@admin.register(models.Contact)
class ContactAdmin(admin.ModelAdmin):
    pass


@admin.register(models.ContactNote)
class ContactNoteAdmin(admin.ModelAdmin):
    pass

Enter fullscreen mode Exit fullscreen mode
  • Reload the django admin interface and you should now see our models

Django admin with models displayed

  • Go ahead and create some contact and some notes via the admin interface and then we can get back to the fun stuff.
  • Let's make a slight change to add a nick_name field to our contact model
class Contact(AppModel):
    first_name = models.CharField(max_length=50)
    middle_name = models.CharField(blank=True, max_length=50, null=True)
    last_name = models.CharField(max_length=50)
    nickname = models.CharField(blank=True, max_length=50, null=True) # <- we added this
    email_address = models.EmailField(max_length=200, unique=True)
    phone_number = models.CharField(max_length=20, unique=True)

    def __str__(self):
        return f'({self.id}) {self.first_name} {self.last_name}'
Enter fullscreen mode Exit fullscreen mode
  • Let's make migrations and then apply them
./manage.py makemigrations
./manage.py migrate
Enter fullscreen mode Exit fullscreen mode

Migrations applied

  • Let's set up our model forms which we will use later for validation by creating the file contact/forms.py
from django.forms import ModelForm
from contact.models import Contact, ContactNote

# We are letting Django's model forms do the work for us. We are only scratching the surface of what can be done with Model forms.


class ContactForm(ModelForm):
    """
    Model form used for just validating our data since we're sending data via InertiaJS
    """
    class Meta:
        model = Contact
        exclude = ['created_at', 'updated_at']


class ContactNoteForm(ModelForm):
    """
    Model form used for just validating our data since we're sending data via InertiaJS
    """
    class Meta:
        model = ContactNote
        exclude = ['created_at', 'updated_at']

Enter fullscreen mode Exit fullscreen mode

ℹ️ By specifying exclude, we are telling the model form to use all fields but exclude the ones specified. For more information on Django Model Forms, click here.

  • Let's work on a way to get our data from our database by creating a file called dataSource.py. We are keeping things simple here to keep the focus on InertiaJs.
import datetime

from contact.models import Contact, ContactNote
from contact.forms import ContactForm, ContactNoteForm


def get_contact_summary():
    """
    Retrieves the count of contacts and contact notes
    """
    try:
        total_contacts = Contact.objects.count()
    except Exception as err:
        total_contacts = 0

    try:
        total_notes = ContactNote.objects.count()
    except Exception as err:
        total_notes = 0

    return {
        'totalContacts': total_contacts,
        'totalNotes': total_notes,
    }

# we'll add more to this later

Enter fullscreen mode Exit fullscreen mode
  • Let's make some changes to our index view in contact_manager.views so that we can have our contact summary displayed
from inertia import inertia

from contact.dataSource import get_contact_summary


@inertia('Home/Index')
def index(request):
    return {
        'summary': get_contact_summary()
    }

Enter fullscreen mode Exit fullscreen mode
  • Next, we'll modify the corresponding component to show our summary and to make sure things are working. I opted to rewrite the component, but it still works the same way
import { FC } from 'react';

interface HomePageProps {
  summary: {
    totalContacts: number;
    totalNotes: number;
  };
}

const HomeIndex: FC<HomePageProps> = ({ summary }) => {
  return (
    <div>
      <h1>Contact Summary</h1>
      <div>
        <div>
          <h2>{summary.totalContacts}</h2>
          <p>Total Contacts</p>
        </div>
        <div>
          <h2>{summary.totalNotes}</h2>
          <p>Total Contact Notes</p>
        </div>
      </div>
    </div>
  );
};

export default HomeIndex;

Enter fullscreen mode Exit fullscreen mode

Contact Summary Updated

Now would be a pretty good time to get some styling setup. We'll be using MUI React because we're efficient like that.

  • Let's install Material UI
npm i -D @mui/material @emotion/react @emotion/styled @fontsource/roboto
Enter fullscreen mode Exit fullscreen mode

NPM package install

  • Let's restructure our Home/Index.tsx component so it looks somewhat presentable
  • We will create a card component so we can display information on the home page nicely components/MetricCard.tsx
import React from 'react';
import type {FC} from 'react';
import { Card, CardActions, CardContent, Typography } from '@mui/material';

interface MetricCardProps {
  actions?: React.ReactElement;
  label: string;
  value: number | string;
}

export const MetricCard: FC<MetricCardProps> = ({ actions, label, value }) => {
  return (
    <Card variant="outlined">
      <CardContent>
        <Typography sx={{ fontSize: 80 }} variant="body1">
          {value}
        </Typography>
        <Typography sx={{ fontSize: 20 }} variant="h2">
          {label}
        </Typography>
      </CardContent>
      {actions && <CardActions>{actions}</CardActions>}
    </Card>
  );
};
Enter fullscreen mode Exit fullscreen mode
  • Let's update Home/Index.tsx
import { FC } from 'react';
import { Inertia } from '@inertiajs/inertia';
import { Box, Button, Grid, Typography } from '@mui/material';
import { MetricCard } from '../../components/MetricCard';

interface HomePageProps {
  summary: {
    totalContacts: number;
    totalNotes: number;
  };
}

const HomeIndex: FC<HomePageProps> = ({ summary }) => {
  return (
    <Box sx={{ flexGrow: 1 }}>
      <Typography variant="h2">Contact Summary</Typography>
      <Typography variant="body1">
        Here's a summary of your contact list and notes
      </Typography>
      <Box sx={{ flexGrow: 1 }}>
        <Grid container direction="row" spacing={1}>
          <Grid item xs={12} md={6}>
            <MetricCard
              actions={
                <>
                  <Button variant="outlined">Add New Contact</Button>{' '}
                  <Button
                    onClick={() => Inertia.visit('/contact')}
                    variant="outlined"
                  >
                    View all Contacts
                  </Button>{' '}
                </>
              }
              label="Total Contacts"
              value={summary.totalContacts}
            />
          </Grid>
          <Grid item xs={12} md={6}>
            <MetricCard
              actions={
                <>
                  <Button variant="outlined">View all notes</Button>{' '}
                </>
              }
              label="Total Notes"
              value={summary.totalNotes}
            />
          </Grid>
        </Grid>
      </Box>
    </Box>
  );
};

export default HomeIndex;
Enter fullscreen mode Exit fullscreen mode
  • Let's add some icons via NPM
npm install -D @mui/icons-material
Enter fullscreen mode Exit fullscreen mode
  • Let's update our layout file components/Layout.tsx
import React from 'react';
import type { FC } from 'react';
import { AppBar, Container, Toolbar, Typography } from '@mui/material';
import ContactsIcon from '@mui/icons-material/Contacts';

interface LayoutProps {
  // For our purposes, ReactNode will be fine.
  children: React.ReactNode;
}

const Layout: FC<LayoutProps> = ({ children }) => {
  return (
    <main>
      <AppBar position="static">
        <Container maxWidth="xl">
          <Toolbar disableGutters>
            <ContactsIcon sx={{ display: { xs: 'none', md: 'flex' }, mr: 1 }} />
            <Typography
              variant="h6"
              noWrap
              component="a"
              href="/"
              sx={{
                mr: 2,
                display: { xs: 'none', md: 'flex' },
                fontFamily: 'monospace',
                fontWeight: 700,
                letterSpacing: '.15rem',
                color: 'inherit',
                textDecoration: 'none',
              }}
            >
              Contact Manager - InertiaJs
            </Typography>
          </Toolbar>
        </Container>
      </AppBar>
      <div>{children}</div>
    </main>
  );
};

export default (page: React.ReactNode | React.ReactElement) => (
  <Layout>{page}</Layout>
);

Enter fullscreen mode Exit fullscreen mode

Updated layout

Now we're getting somewhere. Let's press on 👍

  • Let's work on our contact list functionality by editing the contact/views.py
from django.shortcuts import redirect
from inertia import inertia

from contact import dataSource


@inertia('Contact/Listing')
def contacts(request):
    return dataSource.get_contacts()
Enter fullscreen mode Exit fullscreen mode
  • Because we're doing things the typescript way, let's create an interface based on Contact model in react-app/src/interfaces/contact.interface.ts
export interface Contact {
  id: number;
  first_name: string;
  middle_name?: string;
  last_name: string;
  nickname?: string;
  email_address: string;
  phone_number: string;
}

Enter fullscreen mode Exit fullscreen mode
  • Let's create our contact listing react-app/src/pages/Contact/Listing.tsx
import React from 'react';
import type { FC } from 'react';
import {
  Box,
  Divider,
  IconButton,
  List,
  ListItem,
  ListItemIcon,
  ListItemText,
} from '@mui/material';
import {
  DeleteForeverOutlined as DeleteIcon,
  EditRounded as EditIcon,
  InfoOutlined as InfoIcon,
  Person2Rounded as PersonIcon,
} from '@mui/icons-material';

import type { Contact } from '../../interfaces/contact.interface';

interface ListingProps {
  contacts: Contact[];
}

const Listing: FC<ListingProps> = ({ contacts }) => {
  return (
    <List sx={{ width: '100%' }}>
      {contacts.map((contact, index) => (
        <Box key={`item-${index}`}>
          {index ? <Divider /> : null}
          <ListItem
            key={`contact-${contact.id}`}
            alignItems="center"
            secondaryAction={
              <>
                <IconButton>
                  <InfoIcon />
                </IconButton>
                <IconButton>
                  <EditIcon />
                </IconButton>
                <IconButton>
                  <DeleteIcon />
                </IconButton>
              </>
            }
          >
            <ListItemIcon>
              <PersonIcon />
            </ListItemIcon>
            <ListItemText
              primary={`${contact.first_name} ${contact.last_name}`}
              secondary={contact.phone_number}
            />
          </ListItem>
        </Box>
      ))}
    </List>
  );
};

export default Listing;

Enter fullscreen mode Exit fullscreen mode

Listing layout

Not too shabby for our listing. Of course, you are free to go for maximum style points if you'd like

  • Since we're looking at our listing, let's take this time to create a contact info modal component react-app/src/components/ContactInfoDialog.tsx
import type { FC } from 'react';
import {
  Button,
  Dialog,
  DialogActions,
  DialogContent,
  DialogTitle,
  Grid,
  ListItemText,
} from '@mui/material';
import { Close as CloseIcon, Edit as EditIcon } from '@mui/icons-material';

import type { Contact } from '../interfaces/contact.interface';

interface ContactInfoDialogProps {
  contact?: Contact;
  onClose: () => void;
  onEdit: () => void;
  open: boolean;
}

export const ContactInfoDialog: FC<ContactInfoDialogProps> = ({
  contact,
  onClose,
  onEdit,
  open,
}) => {
  return (
    <Dialog fullWidth maxWidth="sm" onClose={onClose} open={open}>
      <DialogTitle>Contact Information</DialogTitle>
      <DialogContent>
        <Grid container spacing={1}>
          <Grid item xs={4}>
            <ListItemText
              primary={contact?.first_name}
              secondary="First Name"
            />
          </Grid>
          <Grid item xs={4}>
            <ListItemText
              primary={contact?.middle_name || 'N/A'}
              secondary="Middle Name"
            />
          </Grid>
          <Grid item xs={4}>
            <ListItemText primary={contact?.last_name} secondary="Last Name" />
          </Grid>
          <Grid item xs={12}>
            <ListItemText
              primary={contact?.nickname || 'N/A'}
              secondary="Nickname / Alias"
            />
          </Grid>
          <Grid item xs={6}>
            <ListItemText
              primary={contact?.email_address}
              secondary="Email Address"
            />
          </Grid>
          <Grid item xs={6}>
            <ListItemText
              primary={contact?.phone_number}
              secondary="Phone Number"
            />
          </Grid>
        </Grid>
      </DialogContent>
      <DialogActions>
        <Button onClick={onEdit} startIcon={<EditIcon />}>
          Edit Contact
        </Button>
        <Button onClick={onClose} startIcon={<CloseIcon />}>
          Close
        </Button>
      </DialogActions>
    </Dialog>
  );
};
Enter fullscreen mode Exit fullscreen mode
  • We'll need to edit our listing react-app/src/pages/Contact/Listing.tsx so that we can use the newly created dialog
// modified react import
import React, { useCallback, useMemo, useState } from 'react';
import type { FC } from 'react';
import { Inertia } from '@inertiajs/inertia';
import {
  Box,
  Divider,
  Fab,
  IconButton,
  List,
  ListItem,
  ListItemIcon,
  ListItemText,
} from '@mui/material';
import {
  AddCircleOutlineRounded as AddIcon,
  DeleteForeverOutlined as DeleteIcon,
  EditRounded as EditIcon,
  InfoOutlined as InfoIcon,
  Person2Rounded as PersonIcon,
} from '@mui/icons-material';

import type { Contact } from '../../interfaces/contact.interface';
import { ContactInfoDialog } from '../../components/ContactInfoDialog';

interface ListingProps {
  contacts: Contact[];
}

const Listing: FC<ListingProps> = ({ contacts }) => {
  const [selectedContactId, setSelectedContactId] = useState<
    number | undefined
  >();

  // this was added
  const closeContactInfoModal = useCallback(() => {
    setSelectedContactId(undefined);
  }, []);

  // this was also added - we'll use this later
  const editContact = useCallback((contactId: number) => {
    Inertia.visit(`/contact/${contactId}`);
  }, []);

  const selectedContact: Contact | undefined = useMemo(() => {
    try {
      return contacts.find((contact) => contact.id === selectedContactId);
    } catch (e) {
      return undefined;
    }
  }, [contacts, selectedContactId]);

  return (
    <>
      <List sx={{ width: '100%' }}>
        {contacts.map((contact, index) => (
          <Box key={`item-${index}`}>
            {index ? <Divider /> : null}
            <ListItem
              key={`contact-${contact.id}`}
              alignItems="center"
              secondaryAction={
                <>
                  <IconButton onClick={() => setSelectedContactId(contact.id)}>
                    <InfoIcon />
                  </IconButton>
                  <IconButton onClick={() => editContact(contact.id)}>
                    <EditIcon />
                  </IconButton>
                  <IconButton>
                    <DeleteIcon />
                  </IconButton>
                </>
              }
            >
              <ListItemIcon>
                <PersonIcon />
              </ListItemIcon>
              <ListItemText
                primary={`${contact.first_name} ${contact.last_name}`}
                secondary={contact.phone_number}
              />
            </ListItem>
          </Box>
        ))}
      </List>
      <ContactInfoDialog
        contact={selectedContact}
        onClose={closeContactInfoModal}
        onEdit={
            // we'll use this when we're ready to edit contacts
          selectedContact ? () => editContact(selectedContact.id) : () => {}
        }
        open={!!selectedContact}
      />
      <Fab
        color="primary"
        onClick={() => Inertia.visit('/contact/add')}
        sx={{ bottom: 20, position: 'absolute', right: 20 }}
      >
        <AddIcon />
      </Fab>
    </>
  );
};

export default Listing;

Enter fullscreen mode Exit fullscreen mode

Contact Modal

Not bad at all for our contact information dialog, right? Let's proceed to the edit functionality and then we'll handle the delete.

  • We're going to have a reusable notification component, so let's implement that right now react-app/src/components/Notification.tsx
import React from 'react';
import type { FC } from 'react';
import type { AlertColor } from '@mui/material';
import { Alert, Snackbar } from '@mui/material';

interface NotificationProps {
  closeNotification: () => void;
  duration?: number;
  notificationOpen: boolean;
  notificationText: string;
  notificationType?: AlertColor;
}

export const Notification: FC<NotificationProps> = ({
  closeNotification,
  duration = 3000,
  notificationOpen,
  notificationType,
  notificationText,
}) => {
  return (
    <Snackbar
      anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
      autoHideDuration={duration}
      onClose={closeNotification}
      open={notificationOpen}
    >
      <Alert
        onClose={closeNotification}
        severity={notificationType}
        variant="filled"
      >
        {notificationText}
      </Alert>
    </Snackbar>
  );
};

Enter fullscreen mode Exit fullscreen mode
  • Let's go ahead and implement the edit contact page react-app/src/pages/Contact/ContactForm.tsx
import React, { useCallback, useEffect, useState } from 'react';
import type { FC, FormEvent } from 'react';
import { Inertia } from '@inertiajs/inertia';
import { useForm } from '@inertiajs/inertia-react';
import {
  Box,
  Button,
  Container,
  FormControl,
  FormHelperText,
  Grid,
  Input,
  InputLabel,
  Typography,
} from '@mui/material';
import type { AlertColor } from '@mui/material';
import { ContactPhone as ContactIcon } from '@mui/icons-material';
import type { Contact } from '../../interfaces/contact.interface';
import { Notification } from '../../components/Notification';

type FormData = {
  first_name: string;
  middle_name: string;
  last_name: string;
  nickname: string;
  email_address: string;
  phone_number: string;
};

const SUBMIT_RESPONSE_MESSAGES: Record<AlertColor, string> = {
  success: 'Contact information was saved',
  error: 'There was a problem with saving contact information.',
  info: '',
  warning: '',
};

interface ContactFormProps {
  contact?: Contact;
}

const ContactForm: FC<ContactFormProps> = ({ contact }) => {
  const {
    clearErrors,
    data,
    errors,
    post: createContact,
    processing,
    setData,
    put: updateContact,
  } = useForm({
    first_name: '',
    middle_name: '',
    last_name: '',
    nickname: '',
    email_address: '',
    phone_number: '',
  });

  const [contactId, setContactId] = useState<number | undefined>();
  const [notificationType, setNotificationType] = useState<AlertColor>();
  const [notificationOpen, setNotificationOpen] = useState(false);
  const [notificationText, setNotificationText] = useState('');

  useEffect(() => {
    if (contact) {
      setContactId(contact.id);
      setData({
        ...contact,
        middle_name: contact.middle_name || '',
        nickname: contact.nickname || '',
      } as FormData);
    }
  }, [contact]);

  const closeNotification = useCallback(() => {
    if (notificationType === 'success') {
      Inertia.visit('/contact');
    }
    setNotificationOpen(false);
    setNotificationText('');
  }, [notificationType]);

  const handleNotification = useCallback((notificationType: AlertColor) => {
    setNotificationType(notificationType);
    setNotificationText(SUBMIT_RESPONSE_MESSAGES[notificationType]);
    setNotificationOpen(true);
  }, []);

  const submitContactData = useCallback(
    (e: FormEvent<HTMLFormElement>) => {
      e.preventDefault();
      clearErrors();
      contact
        ? updateContact(`/contact/${contactId}/`, {
            onError: () => handleNotification('error'),
            onSuccess: () => {
              handleNotification('success');
              setTimeout(() => {
                Inertia.visit('/contact');
              }, 3000);
            },
          })
        : createContact('', {
            onError: () => handleNotification('error'),
            onSuccess: () => {
              handleNotification('success');
              setTimeout(() => {
                Inertia.visit('/contact');
              }, 3000);
            },
          });
    },
    [contactId, data]
  );

  return (
    <Container maxWidth="md" sx={{ pt: 2, width: '100%' }}>
      <Box
        sx={{
          alignItems: 'center',
          display: 'flex',
          flexDirection: 'row',
          mb: 2,
        }}
      >
        <ContactIcon fontSize="large" />
        <Typography sx={{ fontSize: 40, ml: 1 }} variant="h1">
          {contact ? 'Update' : 'Add New'} Contact
        </Typography>
      </Box>
      <form onSubmit={submitContactData}>
        <Grid container spacing={2}>
          <Grid item sm={4} xs={12}>
            <FormControl error={!!errors.first_name} fullWidth>
              <InputLabel htmlFor="first-name">First Name</InputLabel>
              <Input
                id="first-name"
                onChange={(e) => setData('first_name', e.target.value)}
                placeholder="First Name..."
                required
                value={data.first_name}
              />
              {errors.first_name ? (
                <FormHelperText sx={{ ml: 0 }}>
                  {errors.first_name}
                </FormHelperText>
              ) : null}
            </FormControl>
          </Grid>
          <Grid item sm={4} xs={12}>
            <FormControl error={!!errors.middle_name} fullWidth>
              <InputLabel htmlFor="middle-name">Middle Name</InputLabel>
              <Input
                id="middle-name"
                onChange={(e) => setData('middle_name', e.target.value)}
                placeholder="Middle Name..."
                type="text"
                value={data.middle_name}
              />
              {errors.middle_name ? (
                <FormHelperText sx={{ ml: 0 }}>
                  {errors.middle_name}
                </FormHelperText>
              ) : null}
            </FormControl>
          </Grid>
          <Grid item sm={4} xs={12}>
            <FormControl error={!!errors.last_name} fullWidth>
              <InputLabel htmlFor="last-name">Last Name</InputLabel>
              <Input
                id="last-name"
                onChange={(e) => setData('last_name', e.target.value)}
                placeholder="Last Name..."
                required
                value={data.last_name}
              />
              {errors.last_name ? (
                <FormHelperText sx={{ ml: 0 }}>
                  {errors.last_name}
                </FormHelperText>
              ) : null}
            </FormControl>
          </Grid>
          <Grid item xs={12}>
            <FormControl error={!!errors.nickname} fullWidth>
              <InputLabel>Nickname</InputLabel>
              <Input
                error={!!errors.nickname}
                id="nickname"
                onChange={(e) => setData('nickname', e.target.value)}
                placeholder="Nickname..."
                value={data.nickname}
              />
              {errors.nickname ? (
                <FormHelperText sx={{ ml: 0 }}>
                  {errors.nickname}
                </FormHelperText>
              ) : null}
            </FormControl>
          </Grid>
          <Grid item sm={6} xs={12}>
            <FormControl error={!!errors.email_address} fullWidth>
              <InputLabel htmlFor="email-address">Email Address</InputLabel>
              <Input
                id="email-address"
                onChange={(e) => setData('email_address', e.target.value)}
                placeholder="user@example.com"
                required
                type="email"
                value={data.email_address}
              />
              {errors.email_address ? (
                <FormHelperText sx={{ ml: 0 }}>
                  {errors.email_address}
                </FormHelperText>
              ) : null}
            </FormControl>
          </Grid>
          <Grid item sm={6} xs={12}>
            <FormControl error={!!errors.phone_number} fullWidth>
              <InputLabel htmlFor="phone-number">Phone Number</InputLabel>
              <Input
                id="phone-number"
                onChange={(e) => setData('phone_number', e.target.value)}
                placeholder="555-0001"
                required
                value={data.phone_number}
              />
              {errors.phone_number ? (
                <FormHelperText sx={{ ml: 0 }}>
                  {errors.phone_number}
                </FormHelperText>
              ) : null}
            </FormControl>
          </Grid>
        </Grid>
        <Box sx={{ mt: 2 }}>
          <Button
            disabled={processing}
            fullWidth
            type="submit"
            variant="contained"
          >
            {contact ? 'Update' : 'Save New'} Contact
          </Button>
        </Box>
      </form>
      <Notification
        closeNotification={closeNotification}
        notificationOpen={notificationOpen}
        notificationText={notificationText}
        notificationType={notificationType}
      />
    </Container>
  );
};

export default ContactForm;
Enter fullscreen mode Exit fullscreen mode
  • Next, let's set up our view so that we can navigate to the contact form by editing contact/views.py
# Add the function to contact/views.py

@inertia('Contact/ContactForm')
def add_contact(request):
    if request.method == 'GET':
        return {}

    result = dataSource.upsert_contact(json.loads(request.body))
    return result
Enter fullscreen mode Exit fullscreen mode
  • Let's update our contact urls in contact/urls.py
from django.urls import path

from contact import views

urlpatterns = [
    path('', views.contact_list, name='contact-list'),
        path('add/', views.add_contact, name='add-contact'),
]

Enter fullscreen mode Exit fullscreen mode
  • Let's edit our listing to add a New Contact button and hook it up
// add these imports
import { Fab } from '@mui/material';
import { Inertia } from '@inertiajs/inertia';

// Add this near the bottom of the markup in the JSX 
<Fab
  color="primary"
  onClick={() => Inertia.visit('/contact/add')}
  sx={{ bottom: 20, position: 'absolute', right: 20 }}
>
  <AddIcon />
</Fab>
Enter fullscreen mode Exit fullscreen mode
  • Let's try try navigating to our newly created contact form by clicking the FAB we added

contact form

  • Since we're using Django's Model Forms, we get backend validation that we can tap into. Let's try it out

ℹ️ This would be a good time to also add in some Front-end validation with whatever method you would like.

validation errors working

  • Let's try to save a contact and see what happens

Contact Added

Newly added contact details

  • Let's work on implementing the edit functionality by updating our contact/views.py file as follows
# add this to end of the file
@inertia('Contact/ContactForm')
def contact(request, pk):
    if request.method == 'GET':
        data = dataSource.get_contact(pk)
        return {
            'success': True if data is not None else False,
            'contact': data
        }

    result = dataSource.upsert_contact(json.loads(request.body), pk)
    return result
Enter fullscreen mode Exit fullscreen mode
  • Update our urls contact/url.py file as follows
from django.urls import path

from contact import views

urlpatterns = [
    path('', views.contact_list, name='contact-list'),
    path('add/', views.add_contact, name='add-contact'),
    path('<int:pk>/', views.contact, name='contact'), # <- we added this path
]
Enter fullscreen mode Exit fullscreen mode

With this in place, we should be able to click the edit button from the listing or the contact info modal and have the contact form load with details. The save button should also work since we wired that up earlier. Go ahead and try it.

Updating contacts work

Let's move on to the delete functionality as no contact manager would be complete without delete functionality.

  • Edit contact/dataSource.py by adding the function below to the end of the file
def delete_contact(contact_id):
    result = {
        'errors': '',
        'success': False
    }
    try:
        Contact.objects.get(contact_id).delete()
        result['success'] = True
    except Exception as err:
        result['errors'] = err.__str__()

    return result
Enter fullscreen mode Exit fullscreen mode
  • Let's also update our views contact/views.py by adding the following code to the end of the file
def delete_contact(request, pk):
    if request.method != 'DELETE':
        return redirect('contact_list')

    result = dataSource.delete_contact(pk)
    if not result['success']:
        return result

    return redirect('contact-list')
Enter fullscreen mode Exit fullscreen mode
  • Let's update our urls contact/urls.py so that it looks like what is below
from django.urls import path

from contact import views

urlpatterns = [
    path('', views.contact_list, name='contact-list'),
    path('add/', views.add_contact, name='add-contact'),
    path('<int:pk>/', views.contact, name='contact'),
    path('<int:pk>/delete/', views.delete_contact), # <- we added this
]
Enter fullscreen mode Exit fullscreen mode
  • You guys might notice that I like using dialogs, so we're going to make a question dialog called QuestionDialog 😼 in our components folder. react-app/src/components/QuestionDialog.tsx
import React from 'react';
import type { FC } from 'react';

import {
  Button,
  Dialog,
  DialogActions,
  DialogContentText,
  DialogTitle,
  DialogContent,
} from '@mui/material';
import { DeleteForeverOutlined as DeleteIcon } from '@mui/icons-material';
import type { Contact } from '../interfaces/contact.interface';

interface QuestionDialogProps {
  cancelAction: () => void;
  confirmAction: () => void;
  contact?: Contact;
  open: boolean;
}

export const QuestionDialog: FC<QuestionDialogProps> = ({
  cancelAction,
  confirmAction,
  contact,
  open,
}) => {
  return (
    <Dialog open={open} onClose={cancelAction}>
      <DialogTitle id="question-dialog-title">
        {`Delete Contact: ${contact?.first_name} ${contact?.last_name}`}?
      </DialogTitle>
      <DialogContent>
        <DialogContentText id="question-dialog-prompt-text">
          {contact
            ? `You are about to delete ${contact.first_name} ${
                contact.last_name
              } ${
                contact.nickname ? `[${contact.nickname}]` : ''
              }. Would you like to continue?`
            : ''}
        </DialogContentText>
      </DialogContent>
      <DialogActions>
        <Button onClick={cancelAction}>No</Button>
        <Button
          onClick={confirmAction}
          startIcon={<DeleteIcon />}
          variant="contained"
        >
          Yes
        </Button>
      </DialogActions>
    </Dialog>
  );
};
Enter fullscreen mode Exit fullscreen mode

ℹ️ We could have made a dialog wrapper component since the structure and behavior of the dialogs are very similar (we can do that next time or, you could do it as a challenge)

  • Let's update our contact listing as follows
import React, { useCallback, useMemo, useState } from 'react';
import type { FC } from 'react';
import { Inertia } from '@inertiajs/inertia';
import {
  Box,
  Divider,
  Fab,
  IconButton,
  List,
  ListItem,
  ListItemIcon,
  ListItemText,
} from '@mui/material';
import type { AlertColor } from '@mui/material';
import {
  AddCircleOutlineRounded as AddIcon,
  DeleteForeverOutlined as DeleteIcon,
  EditRounded as EditIcon,
  InfoOutlined as InfoIcon,
  Person2Rounded as PersonIcon,
} from '@mui/icons-material';

import type { Contact } from '../../interfaces/contact.interface';
import { ContactInfoDialog } from '../../components/ContactInfoDialog';
import { QuestionDialog } from '../../components/QuestionDialog';
import { Notification } from '../../components/Notification';

type DialogType = 'info' | 'question' | 'none';

const CONTACT_LIST_MESSAGES: Record<AlertColor, string> = {
  error: 'An error occurred while trying to delete contact.',
  info: '',
  success: 'Contact was deleted successfully.',
  warning: '',
};

interface ListingProps {
  contacts: Contact[];
}

const Listing: FC<ListingProps> = ({ contacts }) => {
  const [selectedContactId, setSelectedContactId] = useState<
    number | undefined
  >();
  const [dialogType, setDialogType] = useState<DialogType>('none');
  const [notificationType, setNotificationType] = useState<AlertColor>();
  const [notificationOpen, setNotificationOpen] = useState(false);
  const [notificationText, setNotificationText] = useState('');

  const closeContactInfoModal = useCallback(() => {
    setSelectedContactId(undefined);
    setDialogType('none');
  }, []);

  const editContact = useCallback((contactId: number) => {
    Inertia.visit(`/contact/${contactId}`);
  }, []);

  const showContactInfo = useCallback((contactId: number) => {
    setDialogType('info');
    setSelectedContactId(contactId);
  }, []);

  const closeQuestionDialog = useCallback(() => {
    setDialogType('none');
    setSelectedContactId(undefined);
  }, []);

  const deleteContact = useCallback((contactId: number) => {
    Inertia.delete(`${contactId}/delete`, {
      onError: () => {
        setNotificationType('error');
        setNotificationText(CONTACT_LIST_MESSAGES['error']);
        setNotificationOpen(true);
      },
      onSuccess: () => {
        setNotificationType('success');
        setNotificationText(CONTACT_LIST_MESSAGES['success']);
        setNotificationOpen(true);
      },
    });
  }, []);

  const deleteContactPrompt = useCallback((contactId: number) => {
    setDialogType('question');
    setSelectedContactId(contactId);
  }, []);

  const resetNotification = useCallback(() => {
    setNotificationOpen(false);
    setNotificationText('');
    setNotificationType(undefined);
  }, []);

  const selectedContact: Contact | undefined = useMemo(() => {
    try {
      return contacts.find((contact) => contact.id === selectedContactId);
    } catch (e) {
      return undefined;
    }
  }, [contacts, selectedContactId]);

  return (
    <>
      <List sx={{ width: '100%' }}>
        {contacts.map((contact, index) => (
          <Box key={`item-${index}`}>
            {index ? <Divider /> : null}
            <ListItem
              key={`contact-${contact.id}`}
              alignItems="center"
              secondaryAction={
                <>
                  <IconButton onClick={() => showContactInfo(contact.id)}>
                    <InfoIcon />
                  </IconButton>
                  <IconButton onClick={() => editContact(contact.id)}>
                    <EditIcon />
                  </IconButton>
                  <IconButton onClick={() => deleteContactPrompt(contact.id)}>
                    <DeleteIcon />
                  </IconButton>
                </>
              }
            >
              <ListItemIcon>
                <PersonIcon />
              </ListItemIcon>
              <ListItemText
                primary={`${contact.first_name} ${contact.last_name}`}
                secondary={contact.phone_number}
              />
            </ListItem>
          </Box>
        ))}
      </List>
      <ContactInfoDialog
        contact={selectedContact}
        onClose={closeContactInfoModal}
        onEdit={
          selectedContact ? () => editContact(selectedContact.id) : () => {}
        }
        open={!!selectedContact && dialogType === 'info'}
      />
      <QuestionDialog
        cancelAction={closeQuestionDialog}
        confirmAction={
          selectedContact ? () => deleteContact(selectedContact.id) : () => {}
        }
        contact={selectedContact}
        open={!!selectedContact && dialogType === 'question'}
      />
      <Fab
        color="primary"
        onClick={() => Inertia.visit('/contact/add')}
        sx={{ bottom: 20, position: 'absolute', right: 20 }}
      >
        <AddIcon />
      </Fab>
      <Notification
        closeNotification={resetNotification}
        notificationOpen={notificationOpen}
        notificationText={notificationText}
        notificationType={notificationType}
      />
    </>
  );
};

export default Listing;

Enter fullscreen mode Exit fullscreen mode

Question Dialog

At this point, we should be able to delete a record and have it actually disappear from our listing. Go ahead and test it. If it all worked correctly, then you should see something like the image below.

Delete contact confirmation

Looking pretty good, right? At this point, everything should be working without issue, if that's the case, feel free celebrate.

  • Now would be a good time to commit the code we wrote. You guys already know how to do that since you're pros.

Upgrading to InertiaJS 1.x

We're now at the part where we can go ahead and upgrade our project to InertiaJs 1.x. We will have to do a fair bit of refactoring but I promise that it won't be difficult. You can review the InertiaJs migration docs here.

  • Make a new branch for the upgrade
  • Go ahead and stop Vite (if already running)
  • Remove the old InertiaJs dependencies
# we didn't use @inertiajs/server so it was never installed
npm remove @inertiajs/inertia @inertiajs/inertia-react @inertiajs/progress
Enter fullscreen mode Exit fullscreen mode
  • Install the new dependency
npm install -D @inertiajs/react
Enter fullscreen mode Exit fullscreen mode

Remove old deps, add new dep

  • In our react-app/src/main.jsx file, let's update our InertiaJs import
import React from 'react';
import { createRoot } from 'react-dom/client';
// Remove these lines
// - import { createInertiaApp } from '@inertiajs/inertia-react';
// - import { InertiaProgress } from '@inertiajs/progress';
// Add this line
import { createInertiaApp } from '@inertiajs/react';
import Layout from './components/Layout.tsx';

const pages = import.meta.glob('./pages/**/*.tsx');

document.addEventListener('DOMContentLoaded', () => {
  createInertiaApp({
    resolve: async (name) => {
      const page = (await pages[`./pages/${name}.tsx`]()).default;
      page.layout = page.layout || Layout;

      return page;
    },
    setup({ el, App, props }) {
      createRoot(el).render(<App {...props} />);
    },
  }).then();
});
Enter fullscreen mode Exit fullscreen mode
  • Let's update the code in react-app/src/pages/Contact/Listing.tsx. Here, we're updating our imports and replacing all usages of Inertia.visit() with router.visit() and Inertia.delete() with router.delete()
import React, { useCallback, useMemo, useState } from 'react';
import type { FC } from 'react';
// import { Inertia } from '@inertiajs/inertia';
import { router } from '@inertiajs/react';
import {
  Box,
  Divider,
  Fab,
  IconButton,
  List,
  ListItem,
  ListItemIcon,
  ListItemText,
} from '@mui/material';
import type { AlertColor } from '@mui/material';
import {
  AddCircleOutlineRounded as AddIcon,
  DeleteForeverOutlined as DeleteIcon,
  EditRounded as EditIcon,
  InfoOutlined as InfoIcon,
  Person2Rounded as PersonIcon,
} from '@mui/icons-material';

import type { Contact } from '../../interfaces/contact.interface';
import { ContactInfoDialog } from '../../components/ContactInfoDialog';
import { QuestionDialog } from '../../components/QuestionDialog';
import { Notification } from '../../components/Notification';

type DialogType = 'info' | 'question' | 'none';

const CONTACT_LIST_MESSAGES: Record<AlertColor, string> = {
  error: 'An error occurred while trying to delete contact.',
  info: '',
  success: 'Contact was deleted successfully.',
  warning: '',
};

interface ListingProps {
  contacts: Contact[];
}

const Listing: FC<ListingProps> = ({ contacts }) => {
  const [selectedContactId, setSelectedContactId] = useState<
    number | undefined
  >();
  const [dialogType, setDialogType] = useState<DialogType>('none');
  const [notificationType, setNotificationType] = useState<AlertColor>();
  const [notificationOpen, setNotificationOpen] = useState(false);
  const [notificationText, setNotificationText] = useState('');

  const closeContactInfoModal = useCallback(() => {
    setSelectedContactId(undefined);
    setDialogType('none');
  }, []);

  const editContact = useCallback((contactId: number) => {
    router.visit(`/contact/${contactId}`);
  }, []);

  const showContactInfo = useCallback((contactId: number) => {
    setDialogType('info');
    setSelectedContactId(contactId);
  }, []);

  const closeQuestionDialog = useCallback(() => {
    setDialogType('none');
    setSelectedContactId(undefined);
  }, []);

  const deleteContact = useCallback((contactId: number) => {
    router.delete(`${contactId}/delete`, {
      onError: () => {
        setNotificationType('error');
        setNotificationText(CONTACT_LIST_MESSAGES['error']);
        setNotificationOpen(true);
      },
      onSuccess: () => {
        setNotificationType('success');
        setNotificationText(CONTACT_LIST_MESSAGES['success']);
        setNotificationOpen(true);
      },
    });
  }, []);

  const deleteContactPrompt = useCallback((contactId: number) => {
    setDialogType('question');
    setSelectedContactId(contactId);
  }, []);

  const resetNotification = useCallback(() => {
    setNotificationOpen(false);
    setNotificationText('');
    setNotificationType(undefined);
  }, []);

  const selectedContact: Contact | undefined = useMemo(() => {
    try {
      return contacts.find((contact) => contact.id === selectedContactId);
    } catch (e) {
      return undefined;
    }
  }, [contacts, selectedContactId]);

  return (
    <>
      <List sx={{ width: '100%' }}>
        {contacts.map((contact, index) => (
          <Box key={`item-${index}`}>
            {index ? <Divider /> : null}
            <ListItem
              key={`contact-${contact.id}`}
              alignItems="center"
              secondaryAction={
                <>
                  <IconButton onClick={() => showContactInfo(contact.id)}>
                    <InfoIcon />
                  </IconButton>
                  <IconButton onClick={() => editContact(contact.id)}>
                    <EditIcon />
                  </IconButton>
                  <IconButton onClick={() => deleteContactPrompt(contact.id)}>
                    <DeleteIcon />
                  </IconButton>
                </>
              }
            >
              <ListItemIcon>
                <PersonIcon />
              </ListItemIcon>
              <ListItemText
                primary={`${contact.first_name} ${contact.last_name}`}
                secondary={contact.phone_number}
              />
            </ListItem>
          </Box>
        ))}
      </List>
      <ContactInfoDialog
        contact={selectedContact}
        onClose={closeContactInfoModal}
        onEdit={
          selectedContact ? () => editContact(selectedContact.id) : () => {}
        }
        open={!!selectedContact && dialogType === 'info'}
      />
      <QuestionDialog
        cancelAction={closeQuestionDialog}
        confirmAction={
          selectedContact ? () => deleteContact(selectedContact.id) : () => {}
        }
        contact={selectedContact}
        open={!!selectedContact && dialogType === 'question'}
      />
      <Fab
        color="primary"
        onClick={() => router.visit('/contact/add')}
        sx={{ bottom: 20, position: 'absolute', right: 20 }}
      >
        <AddIcon />
      </Fab>
      <Notification
        closeNotification={resetNotification}
        notificationOpen={notificationOpen}
        notificationText={notificationText}
        notificationType={notificationType}
      />
    </>
  );
};

export default Listing;

Enter fullscreen mode Exit fullscreen mode
  • Let's update the code in react-app/src/pages/Contact/ContactForm.tsx. Here, we're updating our imports and replacing all usages of Inertia.visit() with router.visit(). We don't have to change the usage of useForm.
import React, { useCallback, useEffect, useState } from 'react';
import type { FC, FormEvent } from 'react';
// remove these lines
// - import { Inertia } from '@inertiajs/inertia';
// - import { useForm } from '@inertiajs/inertia-react';
// Add this line
import {router, useForm} from '@inertiajs/react';
import {
  Box,
  Button,
  Container,
  FormControl,
  FormHelperText,
  Grid,
  Input,
  InputLabel,
  Typography,
} from '@mui/material';
import type { AlertColor } from '@mui/material';
import { ContactPhone as ContactIcon } from '@mui/icons-material';
import type { Contact } from '../../interfaces/contact.interface';
import { Notification } from '../../components/Notification';

type FormData = {
  first_name: string;
  middle_name: string;
  last_name: string;
  nickname: string;
  email_address: string;
  phone_number: string;
};

const SUBMIT_RESPONSE_MESSAGES: Record<AlertColor, string> = {
  success: 'Contact information was saved',
  error: 'There was a problem with saving contact information.',
  info: '',
  warning: '',
};

interface ContactFormProps {
  contact?: Contact;
}

const ContactForm: FC<ContactFormProps> = ({ contact }) => {
  const {
    clearErrors,
    data,
    errors,
    post: createContact,
    processing,
    setData,
    put: updateContact,
  } = useForm({
    first_name: '',
    middle_name: '',
    last_name: '',
    nickname: '',
    email_address: '',
    phone_number: '',
  });

  const [contactId, setContactId] = useState<number | undefined>();
  const [notificationType, setNotificationType] = useState<AlertColor>();
  const [notificationOpen, setNotificationOpen] = useState(false);
  const [notificationText, setNotificationText] = useState('');

  useEffect(() => {
    if (contact) {
      setContactId(contact.id);
      setData({
        ...contact,
        middle_name: contact.middle_name || '',
        nickname: contact.nickname || '',
      } as FormData);
    }
  }, [contact]);

  const closeNotification = useCallback(() => {
    if (notificationType === 'success') {
      router.visit('/contact');
    }
    setNotificationOpen(false);
    setNotificationText('');
  }, [notificationType]);

  const handleNotification = useCallback((notificationType: AlertColor) => {
    setNotificationType(notificationType);
    setNotificationText(SUBMIT_RESPONSE_MESSAGES[notificationType]);
    setNotificationOpen(true);
  }, []);

  const submitContactData = useCallback(
    (e: FormEvent<HTMLFormElement>) => {
      e.preventDefault();
      clearErrors();
      contact
        ? updateContact(`/contact/${contactId}/`, {
            onError: () => handleNotification('error'),
            onSuccess: () => {
              handleNotification('success');
              setTimeout(() => {
                router.visit('/contact');
              }, 3000);
            },
          })
        : createContact('', {
            onError: () => handleNotification('error'),
            onSuccess: () => {
              handleNotification('success');
              setTimeout(() => {
                router.visit('/contact');
              }, 3000);
            },
          });
    },
    [contactId, data]
  );

  return (
    <Container maxWidth="md" sx={{ pt: 2, width: '100%' }}>
      <Box
        sx={{
          alignItems: 'center',
          display: 'flex',
          flexDirection: 'row',
          mb: 2,
        }}
      >
        <ContactIcon fontSize="large" />
        <Typography sx={{ fontSize: 40, ml: 1 }} variant="h1">
          {contact ? 'Update' : 'Add New'} Contact
        </Typography>
      </Box>
      <form onSubmit={submitContactData}>
        <Grid container spacing={2}>
          <Grid item sm={4} xs={12}>
            <FormControl error={!!errors.first_name} fullWidth>
              <InputLabel htmlFor="first-name">First Name</InputLabel>
              <Input
                id="first-name"
                onChange={(e) => setData('first_name', e.target.value)}
                placeholder="First Name..."
                required
                value={data.first_name}
              />
              {errors.first_name ? (
                <FormHelperText sx={{ ml: 0 }}>
                  {errors.first_name}
                </FormHelperText>
              ) : null}
            </FormControl>
          </Grid>
          <Grid item sm={4} xs={12}>
            <FormControl error={!!errors.middle_name} fullWidth>
              <InputLabel htmlFor="middle-name">Middle Name</InputLabel>
              <Input
                id="middle-name"
                onChange={(e) => setData('middle_name', e.target.value)}
                placeholder="Middle Name..."
                type="text"
                value={data.middle_name}
              />
              {errors.middle_name ? (
                <FormHelperText sx={{ ml: 0 }}>
                  {errors.middle_name}
                </FormHelperText>
              ) : null}
            </FormControl>
          </Grid>
          <Grid item sm={4} xs={12}>
            <FormControl error={!!errors.last_name} fullWidth>
              <InputLabel htmlFor="last-name">Last Name</InputLabel>
              <Input
                id="last-name"
                onChange={(e) => setData('last_name', e.target.value)}
                placeholder="Last Name..."
                required
                value={data.last_name}
              />
              {errors.last_name ? (
                <FormHelperText sx={{ ml: 0 }}>
                  {errors.last_name}
                </FormHelperText>
              ) : null}
            </FormControl>
          </Grid>
          <Grid item xs={12}>
            <FormControl error={!!errors.nickname} fullWidth>
              <InputLabel>Nickname</InputLabel>
              <Input
                error={!!errors.nickname}
                id="nickname"
                onChange={(e) => setData('nickname', e.target.value)}
                placeholder="Nickname..."
                value={data.nickname}
              />
              {errors.nickname ? (
                <FormHelperText sx={{ ml: 0 }}>
                  {errors.nickname}
                </FormHelperText>
              ) : null}
            </FormControl>
          </Grid>
          <Grid item sm={6} xs={12}>
            <FormControl error={!!errors.email_address} fullWidth>
              <InputLabel htmlFor="email-address">Email Address</InputLabel>
              <Input
                id="email-address"
                onChange={(e) => setData('email_address', e.target.value)}
                placeholder="user@example.com"
                required
                type="email"
                value={data.email_address}
              />
              {errors.email_address ? (
                <FormHelperText sx={{ ml: 0 }}>
                  {errors.email_address}
                </FormHelperText>
              ) : null}
            </FormControl>
          </Grid>
          <Grid item sm={6} xs={12}>
            <FormControl error={!!errors.phone_number} fullWidth>
              <InputLabel htmlFor="phone-number">Phone Number</InputLabel>
              <Input
                id="phone-number"
                onChange={(e) => setData('phone_number', e.target.value)}
                placeholder="555-0001"
                required
                value={data.phone_number}
              />
              {errors.phone_number ? (
                <FormHelperText sx={{ ml: 0 }}>
                  {errors.phone_number}
                </FormHelperText>
              ) : null}
            </FormControl>
          </Grid>
        </Grid>
        <Box sx={{ mt: 2 }}>
          <Button
            disabled={processing}
            fullWidth
            type="submit"
            variant="contained"
          >
            {contact ? 'Update' : 'Save New'} Contact
          </Button>
        </Box>
      </form>
      <Notification
        closeNotification={closeNotification}
        notificationOpen={notificationOpen}
        notificationText={notificationText}
        notificationType={notificationType}
      />
    </Container>
  );
};

export default ContactForm;

Enter fullscreen mode Exit fullscreen mode
  • Let's restart Vite and if everything worked as it should, we shouldn't notice any changes or errors.

Inertia Upgraded and no errors

Hope you found this guide / tutorial useful. And of course, let me know if you have any questions or comments. There might be a Part 2.5 to this tutorial that would cover the Contact Notes section.

References

The following resources were used in the creation of this part of the tutorial

💖 💪 🙅 🚩
saiforceone
Simon (Sai)

Posted on January 28, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related