Skip to content

Commit 17722f9

Browse files
feat: add book create form and page
1 parent 3e559c7 commit 17722f9

File tree

6 files changed

+251
-2
lines changed

6 files changed

+251
-2
lines changed
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { useFormContext } from 'react-hook-form';
2+
3+
import {
4+
FormField,
5+
FormFieldController,
6+
FormFieldLabel,
7+
} from '@/components/form';
8+
9+
import { FormFieldsBook } from '@/features/book/schema';
10+
11+
export const FormBook = () => {
12+
const form = useFormContext<FormFieldsBook>();
13+
14+
return (
15+
<div className="flex flex-col gap-4">
16+
<FormField>
17+
<FormFieldLabel>Title</FormFieldLabel>
18+
<FormFieldController
19+
type="text"
20+
control={form.control}
21+
name="title"
22+
autoFocus
23+
/>
24+
</FormField>
25+
<FormField>
26+
<FormFieldLabel>Author</FormFieldLabel>
27+
<FormFieldController type="text" control={form.control} name="author" />
28+
</FormField>
29+
30+
<FormField>
31+
<FormFieldLabel>Genre</FormFieldLabel>
32+
<FormFieldController type="text" control={form.control} name="genre" />
33+
</FormField>
34+
35+
<FormField>
36+
<FormFieldLabel>Publisher</FormFieldLabel>
37+
<FormFieldController
38+
type="text"
39+
control={form.control}
40+
name="publisher"
41+
/>
42+
</FormField>
43+
</div>
44+
);
45+
};
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { zodResolver } from '@hookform/resolvers/zod';
2+
import { ORPCError } from '@orpc/client';
3+
import { useMutation, useQueryClient } from '@tanstack/react-query';
4+
import { useBlocker, useCanGoBack, useRouter } from '@tanstack/react-router';
5+
import { useForm } from 'react-hook-form';
6+
import { toast } from 'sonner';
7+
8+
import { orpc } from '@/lib/orpc/client';
9+
10+
import { BackButton } from '@/components/back-button';
11+
import { Form } from '@/components/form';
12+
import { Button } from '@/components/ui/button';
13+
import { Card, CardContent } from '@/components/ui/card';
14+
15+
import { FormBook } from '@/features/book/manager/form-book';
16+
import { zFormFieldsBook } from '@/features/book/schema';
17+
import {
18+
PageLayout,
19+
PageLayoutContent,
20+
PageLayoutTopBar,
21+
PageLayoutTopBarTitle,
22+
} from '@/layout/manager/page-layout';
23+
24+
export const PageBookNew = () => {
25+
const router = useRouter();
26+
const canGoBack = useCanGoBack();
27+
const queryClient = useQueryClient();
28+
const form = useForm({
29+
resolver: zodResolver(zFormFieldsBook()),
30+
values: {
31+
title: '',
32+
author: '',
33+
genre: '',
34+
publisher: '',
35+
},
36+
});
37+
38+
const bookCreate = useMutation(
39+
orpc.book.create.mutationOptions({
40+
onSuccess: async () => {
41+
// Invalidate Users list
42+
await queryClient.invalidateQueries({
43+
queryKey: orpc.book.getAll.key(),
44+
type: 'all',
45+
});
46+
47+
// Redirect
48+
if (canGoBack) {
49+
router.history.back();
50+
} else {
51+
router.navigate({ to: '..', replace: true });
52+
}
53+
},
54+
onError: (error) => {
55+
if (
56+
error instanceof ORPCError &&
57+
error.code === 'CONFLICT' &&
58+
error.data?.target?.includes('title')
59+
) {
60+
form.setError('title', {
61+
message: 'A book by this author already exist',
62+
});
63+
return;
64+
}
65+
66+
toast.error('Failed to create a book');
67+
},
68+
})
69+
);
70+
71+
const formIsDirty = form.formState.isDirty;
72+
useBlocker({
73+
shouldBlockFn: () => {
74+
if (!formIsDirty || bookCreate.isSuccess) return false;
75+
const shouldLeave = confirm('Are you sure you want to leave?');
76+
return !shouldLeave;
77+
},
78+
});
79+
80+
return (
81+
<Form
82+
{...form}
83+
onSubmit={async (values) => {
84+
bookCreate.mutate(values);
85+
}}
86+
>
87+
<PageLayout>
88+
<PageLayoutTopBar
89+
backButton={<BackButton />}
90+
actions={
91+
<Button
92+
size="sm"
93+
type="submit"
94+
className="min-w-20"
95+
loading={bookCreate.isPending}
96+
>
97+
Create
98+
</Button>
99+
}
100+
>
101+
<PageLayoutTopBarTitle>New Book</PageLayoutTopBarTitle>
102+
</PageLayoutTopBar>
103+
<PageLayoutContent>
104+
<Card>
105+
<CardContent>
106+
<FormBook />
107+
</CardContent>
108+
</Card>
109+
</PageLayoutContent>
110+
</PageLayout>
111+
</Form>
112+
);
113+
};

app/features/book/manager/page-books.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,15 @@ export const PageBooks = (props: { search: { searchTerm?: string } }) => {
7676
<PageLayout>
7777
<PageLayoutTopBar
7878
actions={
79-
<ResponsiveIconButton label="New Book" variant="secondary" size="sm">
80-
<PlusIcon />
79+
<ResponsiveIconButton
80+
asChild
81+
label="New Book"
82+
variant="secondary"
83+
size="sm"
84+
>
85+
<Link to="/manager/books/new">
86+
<PlusIcon />
87+
</Link>
8188
</ResponsiveIconButton>
8289
}
8390
>

app/routeTree.gen.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { Route as AppLayoutBooksIndexImport } from './routes/app/_layout/books.i
3131
import { Route as AppLayoutAccountIndexImport } from './routes/app/_layout/account.index'
3232
import { Route as ManagerLayoutUsersNewIndexImport } from './routes/manager/_layout/users.new.index'
3333
import { Route as ManagerLayoutUsersIdIndexImport } from './routes/manager/_layout/users.$id.index'
34+
import { Route as ManagerLayoutBooksNewIndexImport } from './routes/manager/_layout/books.new.index'
3435
import { Route as ManagerLayoutBooksIdIndexImport } from './routes/manager/_layout/books.$id.index'
3536
import { Route as AppLayoutDesktopOnlyBooksIdIndexImport } from './routes/app/_layout-desktop-only/books.$id.index'
3637
import { Route as ManagerLayoutUsersIdUpdateIndexImport } from './routes/manager/_layout/users.$id.update.index'
@@ -157,6 +158,14 @@ const ManagerLayoutUsersIdIndexRoute = ManagerLayoutUsersIdIndexImport.update({
157158
getParentRoute: () => ManagerLayoutRoute,
158159
} as any)
159160

161+
const ManagerLayoutBooksNewIndexRoute = ManagerLayoutBooksNewIndexImport.update(
162+
{
163+
id: '/books/new/',
164+
path: '/books/new/',
165+
getParentRoute: () => ManagerLayoutRoute,
166+
} as any,
167+
)
168+
160169
const ManagerLayoutBooksIdIndexRoute = ManagerLayoutBooksIdIndexImport.update({
161170
id: '/books/$id/',
162171
path: '/books/$id/',
@@ -321,6 +330,13 @@ declare module '@tanstack/react-router' {
321330
preLoaderRoute: typeof ManagerLayoutBooksIdIndexImport
322331
parentRoute: typeof ManagerLayoutImport
323332
}
333+
'/manager/_layout/books/new/': {
334+
id: '/manager/_layout/books/new/'
335+
path: '/books/new'
336+
fullPath: '/manager/books/new'
337+
preLoaderRoute: typeof ManagerLayoutBooksNewIndexImport
338+
parentRoute: typeof ManagerLayoutImport
339+
}
324340
'/manager/_layout/users/$id/': {
325341
id: '/manager/_layout/users/$id/'
326342
path: '/users/$id'
@@ -407,6 +423,7 @@ interface ManagerLayoutRouteChildren {
407423
ManagerLayoutDashboardIndexRoute: typeof ManagerLayoutDashboardIndexRoute
408424
ManagerLayoutUsersIndexRoute: typeof ManagerLayoutUsersIndexRoute
409425
ManagerLayoutBooksIdIndexRoute: typeof ManagerLayoutBooksIdIndexRoute
426+
ManagerLayoutBooksNewIndexRoute: typeof ManagerLayoutBooksNewIndexRoute
410427
ManagerLayoutUsersIdIndexRoute: typeof ManagerLayoutUsersIdIndexRoute
411428
ManagerLayoutUsersNewIndexRoute: typeof ManagerLayoutUsersNewIndexRoute
412429
ManagerLayoutUsersIdUpdateIndexRoute: typeof ManagerLayoutUsersIdUpdateIndexRoute
@@ -419,6 +436,7 @@ const ManagerLayoutRouteChildren: ManagerLayoutRouteChildren = {
419436
ManagerLayoutDashboardIndexRoute: ManagerLayoutDashboardIndexRoute,
420437
ManagerLayoutUsersIndexRoute: ManagerLayoutUsersIndexRoute,
421438
ManagerLayoutBooksIdIndexRoute: ManagerLayoutBooksIdIndexRoute,
439+
ManagerLayoutBooksNewIndexRoute: ManagerLayoutBooksNewIndexRoute,
422440
ManagerLayoutUsersIdIndexRoute: ManagerLayoutUsersIdIndexRoute,
423441
ManagerLayoutUsersNewIndexRoute: ManagerLayoutUsersNewIndexRoute,
424442
ManagerLayoutUsersIdUpdateIndexRoute: ManagerLayoutUsersIdUpdateIndexRoute,
@@ -457,6 +475,7 @@ export interface FileRoutesByFullPath {
457475
'/manager/users': typeof ManagerLayoutUsersIndexRoute
458476
'/app/books/$id': typeof AppLayoutDesktopOnlyBooksIdIndexRoute
459477
'/manager/books/$id': typeof ManagerLayoutBooksIdIndexRoute
478+
'/manager/books/new': typeof ManagerLayoutBooksNewIndexRoute
460479
'/manager/users/$id': typeof ManagerLayoutUsersIdIndexRoute
461480
'/manager/users/new': typeof ManagerLayoutUsersNewIndexRoute
462481
'/manager/users/$id/update': typeof ManagerLayoutUsersIdUpdateIndexRoute
@@ -477,6 +496,7 @@ export interface FileRoutesByTo {
477496
'/manager/users': typeof ManagerLayoutUsersIndexRoute
478497
'/app/books/$id': typeof AppLayoutDesktopOnlyBooksIdIndexRoute
479498
'/manager/books/$id': typeof ManagerLayoutBooksIdIndexRoute
499+
'/manager/books/new': typeof ManagerLayoutBooksNewIndexRoute
480500
'/manager/users/$id': typeof ManagerLayoutUsersIdIndexRoute
481501
'/manager/users/new': typeof ManagerLayoutUsersNewIndexRoute
482502
'/manager/users/$id/update': typeof ManagerLayoutUsersIdUpdateIndexRoute
@@ -504,6 +524,7 @@ export interface FileRoutesById {
504524
'/manager/_layout/users/': typeof ManagerLayoutUsersIndexRoute
505525
'/app/_layout-desktop-only/books/$id/': typeof AppLayoutDesktopOnlyBooksIdIndexRoute
506526
'/manager/_layout/books/$id/': typeof ManagerLayoutBooksIdIndexRoute
527+
'/manager/_layout/books/new/': typeof ManagerLayoutBooksNewIndexRoute
507528
'/manager/_layout/users/$id/': typeof ManagerLayoutUsersIdIndexRoute
508529
'/manager/_layout/users/new/': typeof ManagerLayoutUsersNewIndexRoute
509530
'/manager/_layout/users/$id/update/': typeof ManagerLayoutUsersIdUpdateIndexRoute
@@ -529,6 +550,7 @@ export interface FileRouteTypes {
529550
| '/manager/users'
530551
| '/app/books/$id'
531552
| '/manager/books/$id'
553+
| '/manager/books/new'
532554
| '/manager/users/$id'
533555
| '/manager/users/new'
534556
| '/manager/users/$id/update'
@@ -548,6 +570,7 @@ export interface FileRouteTypes {
548570
| '/manager/users'
549571
| '/app/books/$id'
550572
| '/manager/books/$id'
573+
| '/manager/books/new'
551574
| '/manager/users/$id'
552575
| '/manager/users/new'
553576
| '/manager/users/$id/update'
@@ -573,6 +596,7 @@ export interface FileRouteTypes {
573596
| '/manager/_layout/users/'
574597
| '/app/_layout-desktop-only/books/$id/'
575598
| '/manager/_layout/books/$id/'
599+
| '/manager/_layout/books/new/'
576600
| '/manager/_layout/users/$id/'
577601
| '/manager/_layout/users/new/'
578602
| '/manager/_layout/users/$id/update/'
@@ -659,6 +683,7 @@ export const routeTree = rootRoute
659683
"/manager/_layout/dashboard/",
660684
"/manager/_layout/users/",
661685
"/manager/_layout/books/$id/",
686+
"/manager/_layout/books/new/",
662687
"/manager/_layout/users/$id/",
663688
"/manager/_layout/users/new/",
664689
"/manager/_layout/users/$id/update/"
@@ -716,6 +741,10 @@ export const routeTree = rootRoute
716741
"filePath": "manager/_layout/books.$id.index.tsx",
717742
"parent": "/manager/_layout"
718743
},
744+
"/manager/_layout/books/new/": {
745+
"filePath": "manager/_layout/books.new.index.tsx",
746+
"parent": "/manager/_layout"
747+
},
719748
"/manager/_layout/users/$id/": {
720749
"filePath": "manager/_layout/users.$id.index.tsx",
721750
"parent": "/manager/_layout"
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { createFileRoute } from '@tanstack/react-router';
2+
3+
import { PageBookNew } from '@/features/book/manager/page-book-new';
4+
5+
export const Route = createFileRoute('/manager/_layout/books/new/')({
6+
component: RouteComponent,
7+
});
8+
9+
function RouteComponent() {
10+
return <PageBookNew />;
11+
}

app/server/routers/book.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,4 +101,48 @@ export default {
101101

102102
return book;
103103
}),
104+
105+
create: protectedProcedure({
106+
permission: {
107+
book: ['create'],
108+
},
109+
})
110+
.route({
111+
method: 'POST',
112+
path: '/books',
113+
tags,
114+
})
115+
.input(
116+
zBook().pick({
117+
title: true,
118+
author: true,
119+
genre: true,
120+
publisher: true,
121+
})
122+
)
123+
.output(zBook())
124+
.handler(async ({ context, input }) => {
125+
context.logger.info('Create book');
126+
try {
127+
return await context.db.book.create({
128+
data: {
129+
title: input.title,
130+
author: input.author,
131+
genre: input.genre,
132+
publisher: input.publisher,
133+
},
134+
});
135+
} catch (error: unknown) {
136+
if (
137+
error instanceof Prisma.PrismaClientKnownRequestError &&
138+
error.code === 'P2002'
139+
) {
140+
throw new ORPCError('CONFLICT', {
141+
message: error.message,
142+
data: error.meta,
143+
});
144+
}
145+
throw new ORPCError('INTERNAL_SERVER_ERROR');
146+
}
147+
}),
104148
};

0 commit comments

Comments
 (0)