56
loading...
This website collects cookies to deliver better user experience
/catalog/books/update/[id].js
is a dynamic route. The id
determines the Book
that will be on the update page.// 1
brew services start [email protected]
//2 run the following command to use the mongo shell.
mongo
//3 To print the name of databases run
show dbs
yarn create Strapi-app Strapi-local-library
Custom
(manual settings), and select your default database client: select mongo
and complete the questions with the following info.yarn develop
or npm run develop
wait till it finishes building, then back to mongo
shell and run the following command> use local
> show collections
Book
, Author
, Genre
, and BookInstance
collections. The following image describes the relation between our content types
and the fields that are necessary for each type.Author
has many Books
. Book
has one Author
Book
has and belongs
to many Genres
, Genre
has and belong to many Books
Book
has many BookInstances
, BookInstance
has one Book
Relations
in Strapi for our Content-Types
. To keep the size of the tutorial as low as possible, I'll only cover the creation of the Author and the Book content types, and you can follow the same steps to finish the rest at this stage. npx create-next-app project-name
# or
yarn create next-app project-name
useSWR
is a React Hooks library for data fetching. We'll need it to fetch data on the client side.luxon
for formatting dates.react-hook-form
for forms handling
yarn add swr luxon react-hook-form
config.next.js
file and add to it the following code.module.exports = {
async redirects() {
return [
{
source: '/',
destination: '/catalog',
permanent: true,
},
]
},
}
env.local
file and add the following variable.NEXT_PUBLIC_API_ENDPOINT = "<http://localhost:1337>"
content-types
builder and click create new collection type
button, enter the Display name book
as shown below, and hit Continue
.Text
field for the book title
and click Add
another fieldsummary
(long text) and ISBN
(short text). For the author
field, we need to create the Author
content-type first. Follow previous steps to create the Author
, Genre
, and BookInstance
content types and add a relation
field for the Book
.Book
to Author
, Genre
, and BookInstance
content-types.{
"statusCode":403,
"error":"Forbidden",
"message":"Forbidden"
}
[http://localhost:1337/admin/settings/users-permissions/roles](http://localhost:1337/admin/settings/users-permissions/roles)
, under public > permission > application tap check Select all options for all content types and save.pages/catalog/books/index.js
file and add the following code to it:import Head from 'next/head'
import Link from 'next/link'
import { Card, Grid } from '@geist-ui/react'
export default function Books({data, notFound}) {
return (
<div>
<Head>
<title>Book list</title>
<link rel='icon' href='/favicon.ico' />
</Head>
<section className="main-section">
<h1>
Books
</h1>
{notFound ? <div>not found</div> :
<Grid.Container gap={1}>{
data.map((book) => {
return(
<Grid key={book.id}>
<Card>
<Link style={{ width: '100%'}} href="/catalog/books/details/[id]" as={`/catalog/books/details/${book.id}`} >
<a>
<h4>{book.title}</h4>
</a>
</Link>
<p>author: {book.author.family_name} {book.author.first_name}</p>
</Card>
</Grid>
)
})
}</Grid.Container>
}
</section>
</div>
)
}
export async function getServerSideProps(context) {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_ENDPOINT}/books/?_sort=updated_at:DESC`)
const data = await res.json()
if (!data) {
return {
notFound: true,
}
}
return {
props: {data}, // will be passed to the page component as props
}
}
getServerSideProps
to fetch a list of books and return it as props to our Books page component as data. Then we iterate through the list of books and render the book title
and the Author
.Head
act as the document head tagLink
is for navigating between routesLink
component and getServerSideProps
method, see the NextJS documentation at https://NextJS.org/docs/getting-started
id
field value) and information about each associated copy in the library (BookInstance). Wherever we display an author
, genre
, or bookInstance
, these should be linked to the associated detail page for that item.//pages/catalog/books/details/[id].js
import { Button, Divider, Loading, Modal, Note } from '@geist-ui/react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import useBook from '@/hooks/useBook'
const Book = () => {
const router = useRouter()
const { book, isError, isLoading } = useBook(router.query.id)
return (
<section className="main-section">
{
isError ? "an error occured !" : isLoading ? <Loading /> :
<div>
<div>
<h2>Title:</h2><p>{book.title}</p>
</div>
<div>
<h2>ISBN:</h2><p>{book.ISBN}</p>
</div>
<div>
<h2>Author:</h2> <p>{book.author.family_name} {book.author.first_name}</p>
</div>
<div>
<h2>Summary:</h2><p>{book.summary}</p>
</div>
<div>
<h2>Genre:</h2>
<div>
{
book.genres.length > 0 ? book.genres.map(({name, id}) => {
return(
<div key={id}>
<p>{name}</p>
</div>
)
})
:
'this book dont belong to any genre'
}
</div>
</div>
<div>
<h2>Copies:</h2>
<ul>
{
book.bookinstances.length > 0 ? book.bookinstances.map(({imprint, status, id}) => {
return(
<li key={id}>
<span> {imprint} </span>
<span className={status}> [ {status} ]</span>
</li>
)
})
:
'there are no copies of this book in the library'
}
</ul>
</div>
</div>
}
</section>
)
}
export default Book
u
seRouter
hook comes with NextJS. It allows us to access the router object inside any function component in our App.router.query.id
, then we use the book id to fetch a specific book using our custom useBook
hookuseBook
hook is a custom hook that receives an id
and initialBook
object as parameters and returns a book object matching that id
.
// hooks/useBook.js
import useSWR from 'swr'
import Fetcher from '../utils/Fetcher'
function useBook (id, initialBook) {
const { data, error } = useSWR(`${process.env.NEXT_PUBLIC_API_ENDPOINT}/books/${id}`, Fetcher, { initialData: initialBook })
return {
book: data,
isLoading: !error && !data,
isError: error
}
}
export default useBook
useBook
hook is built on top of SWR, a React Hooks library for data fetching. For more information about how to use it with Next.js, refer to the official docs https://swr.vercel.app/docs/with-NextJS.import { useState } from 'react'
import Head from 'next/head'
import { useRouter } from 'next/router'
import { Button, Loading, Spacer } from '@geist-ui/react'
import useAuthors from '@/hooks/useAuthors'
import useGenres from '@/hooks/useGenres'
import { useForm } from "react-hook-form"
export default function CreateBook() {
const router = useRouter()
const { authors, isError: isAuthorError, isLoading: authorsIsLoading } = useAuthors({initialData: null})
const { genres, isError: isGenreError, isLoading: genresIsLoading } = useGenres()
const { register, handleSubmit } = useForm({mode: "onChange"});
async function createBook(data){
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_ENDPOINT}/books`,
{
body: JSON.stringify({
title: data.title,
author: data.author,
summary: data.summary,
genres: data.genre,
ISBN: data.ISBN,
}),
headers: {
'Content-Type': 'application/json'
},
method: 'POST'
}
)
const result = await res.json()
if(res.ok){
router.push(`/catalog/books/details/${result.id}`)
}
}
return (
<div>
<Head>
<title>Create new Book</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<section className="main-section">
<h1>
New Book
</h1>
{
isAuthorError || isGenreError ? "An error has occurred."
: authorsIsLoading || genresIsLoading ? <Loading />
:
<form id="Book-form" onSubmit={handleSubmit(createBook)}>
<div>
<label htmlFor="title">Title</label>
<input type="text" name="title" id="title" {...register('title')}/>
</div>
<Spacer y={1}/>
<div>
<label htmlFor="author">Author</label>
<select type="text" name="author" id="author" {...register('author')}>
{authors.map((author) => {
return(
<option key={author.id} value={author.id}>
{author.first_name + " " + author.family_name}
</option>
)
})}
</select>
</div>
<Spacer y={1}/>
<div>
<label htmlFor="summary">Summary</label>
<textarea name="summary" id="summary" {...register('summary')}/>
</div>
<Spacer y={1}/>
<div>
{genres.length > 0 ?
genres.map((genre) => {
return(
<div key={genre.id}>
<input
type="checkbox"
value={genre.id}
id={genre.id}
{...register("genre")}
/>
<label htmlFor={genre.id}>{genre.name}</label>
</div>
)
})
: null
}
</div>
<Spacer y={1}/>
<div>
<label htmlFor="ISBN">ISBN</label>
<input type="text" name="ISBN" id="ISBN" {...register('ISBN')}/>
{ISBNError &&
<div style={{
fontSize:"12px",
padding:"8px",
color: "crimson"}}>
book with same ISBN already exist
</div>}
</div>
<Spacer y={2}/>
<Button htmlType="submit" type="success" ghost>Submit</Button>
</form>
}
</section>
</div>
)
}
import { useEffect } from 'react'
import Head from 'next/head'
import { Button, Loading, Spacer } from '@geist-ui/react'
import { withRouter } from 'next/router'
import useGenres from '@/hooks/useGenres'
import useAuthors from '@/hooks/useAuthors'
import useBook from '@/hooks/useBook'
import { useForm } from "react-hook-form"
function UpdateBook({ router, initialBook }) {
const { id } = router.query
// fetching book and genres to populate Author field and display all the genres.
const {genres, isLoading: genresIsLoading, isError: genresIsError} = useGenres()
const {authors, isLoading: authorsIsLoading, isError: AuthorsIsError} = useAuthors({initialData: null})
const { book, isError, isLoading } = useBook(router.query.id ? router.query.id : null, initialBook)
// register form fields
const { register, handleSubmit, reset } = useForm({mode: "onChange"});
useEffect(() => {
const bookGenres = book.genres.map((genre) => {
let ID = genre.id.toString()
return ID
})
reset({
title: book.title,
author: book.author.id,
summary: book.summary,
ISBN: book.ISBN,
genre: bookGenres
});
}, [reset])
// API Call Update Book
async function updateBook(data){
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_ENDPOINT}/books/${id}`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
title: data.title,
author: data.author,
summary: data.summary,
genres: data.genre,
ISBN: data.ISBN,
})
}
)
router.push(`/catalog/books/details/${id}`)
}
return (
<div>
<Head>
<title>Update Book</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<section className="main-section">
<h1>
Update Book
</h1>
{
genresIsError || AuthorsIsError ? "an error occured" : genresIsLoading || authorsIsLoading ? <Loading /> :
<form id="Book-update-form" onSubmit={handleSubmit(updateBook)}>
<div>
<label htmlFor="title">Title</label>
<input type="text" id="title" {...register("title")}/>
</div>
<Spacer y={1}/>
<div>
<label htmlFor="author">Author</label>
<select type="text" id="author" {...register("author")}>
{authors.map((author) => {
return(
<option key={author.id} value={author.id}>
{author.first_name + " " + author.family_name}
</option>
)
})}
</select>
</div>
<Spacer y={1}/>
<div>
<label htmlFor="summary" >Summary</label>
<textarea id="summary" {...register("summary")}/>
</div>
<Spacer y={1}/>
<div>
<label htmlFor="ISBN">ISBN</label>
<input type="text" id="ISBN" {...register("ISBN")}/>
</div>
<Spacer y={1}/>
<div>
{genres.length > 0 ?
genres.map((genre) => {
return(
<div key={genre.id}>
<input
type="checkbox"
value={genre.id}
id={genre.id}
{...register("genre")}
/>
<label htmlFor={genre.id}>{genre.name}</label>
</div>
)
})
: null
}
</div>
<Spacer y={2}/>
<Button auto htmlType="submit" type="success" ghost>Submit</Button>
</form>
}
</section>
</div>
)
}
export default withRouter(UpdateBook)
export async function getStaticPaths() {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_ENDPOINT}/books`)
const books = await res.json()
const paths = books.map((book) => ({
params: { id: book.id.toString() },
}))
return { paths, fallback: false }
}
export async function getStaticProps({ params }) {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_ENDPOINT}/books/${params.id}`)
const initialBook = await res.json()
return {
props: {
initialBook,
},
}
}
getStaticProps
method, the page is pre-rendered with Static Generation (SSG).useForm
hook gives us to populate our form fields within the useEffect
hookpages/catalog/books/details/[id].js
file and update it with the following code...
const Book = () => {
...
const [toggleModal, setToggleModal] = useState(false)
const handler = () => setToggleModal(true)
const closeHandler = (event) => {
setToggleModal(false)
}
async function DeleteBook() {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_ENDPOINT}/books/${router.query.id}`,
{
method:"DELETE",
headers: {
'Content-Type' : 'application/json'
},
body: null
})
setToggleModal(false)
router.push(`/catalog/books`)
}
return (
<section className="main-section">
{
isError ? "an error occured !" : isLoading ? <Loading /> :
<div>
...
<Divider />
<Button style={{marginRight:"1.5vw"}} auto onClick={handler} type="error">Delete book</Button>
<Link href={`/catalog/books/update/${book.id}`}>
<a>
<Button auto type="default">Update book</Button>
</a>
</Link>
<Modal open={toggleModal} onClose={closeHandler}>
{book.bookinstances.length > 0 ?
<>
<Modal.Title>
<Note type="warning">delete the following copies before deleting this book</Note>
</Modal.Title>
<Modal.Content>
<ul>
{book.bookinstances.map((copie) => {
return(
<li key={copie.id}>{copie.imprint}, #{copie.id}</li>
)
})
}
</ul>
</Modal.Content>
</>
:<>
<Modal.Title>CONFIRM DELETE BOOK ?</Modal.Title>
<Modal.Subtitle>This action is ireversible</Modal.Subtitle>
</>
}
<Modal.Action passive onClick={() => setToggleModal(false)}>Cancel</Modal.Action>
<Modal.Action disabled={book.bookinstances.length > 0} onClick={DeleteBook}>Confirm</Modal.Action>
</Modal>
</div>
}
</section>
)
}
export default Book
Book
has at least one BookInstance
. We'll prevent the user from deleting this Book
and showing a list of BookInstances
that must be deleted before deleting the Book
. If the Book has no BookInstances
, we call the DeleteBook
function when the user confirms.book
controller file.// api/book/controllers/book.js
const { sanitizeEntity } = require('Strapi-utils');
module.exports = {
async delete (ctx) {
const { id } = ctx.params;
let entity = await Strapi.services.book.find({ id });
if(entity[0].bookinstances.length > 0) {
return ctx.send({
message: 'book contain one or more instances'
}, 406);
}
entity = await Strapi.services.book.delete({ id });
return sanitizeEntity(entity, { model: Strapi.models.book });
},
async create(ctx) {
let entity;
const { ISBN } = ctx.request.body
entity = await Strapi.services.book.findOne({ ISBN });
if (entity){
return ctx.send({
message: 'book alredy existe'
}, 406);
}
if (ctx.is('multipart')) {
const { data, files } = parseMultipartData(ctx);
entity = await Strapi.services.book.create(data, { files });
} else {
entity = await Strapi.services.book.create(ctx.request.body);
}
return sanitizeEntity(entity, { model: Strapi.models.book });
},
};
Book
has at least one bookInstance
we respond with a 406 not Acceptable
error and a message
, else we allow to delete the Book. ISBN
already exists. We respond with a 406 not Acceptable error
and a message
. Else we allow creating a new book.VARIABLE_NAME = NEXT_PUBLIC_API_ENDPOINT
VALUE = https://your-app-name.herokuapp.com