Full-stack Development with Ionic and Nitric: Building the Notes Application Part 2

New
10 min read
Ryan Edge
Ryan Edge
Published January 6, 2025

In the previous article, we extended our API with endpoints to create and retrieve notes and integrated the new endpoints into the application.

In this article, we will extend our application with full-stack functionality for editing and deleting notes.

Editing Notes

Adding the API Request

Before creating the API, let's set up a dependency to validate the ID used to update or retrieve a note. Create a file in the shared/schemas folder called id.ts and add the following code and resolve its dependencies:

javascript
1
export const idSchema = z.object({ id: z.string() });

This code creates a schema that we will use to validate the id used to edit a note.

Additionally, extend the note.ts file in the shared/schemas with the following code:

export const updateNoteSchema = z.object({
  id: z.string().uuid(),
  title: z.string().min(1),
  content: z.string().min(1),
});

This code creates a schema that we will use to validate the user's request to update a note.

Next, in the notes service, add the following code to the bottom of the file.

javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
notesApi.get("/notes/:id", async (ctx: HttpContextWithAuthentication) => { const { sub: userId } = ctx.authentication.token; const params = idSchema.safeParse(ctx.req.params); if (params.success == false) { ctx.res.status = 400; ctx.res.json(params.error.issues); return ctx; } if (userId == null) { ctx.res.status = 400; return ctx; } const store = getNoteStore("get"); const notes = await store.get(userId); const note = notes.notes.find((note) => note.id === params.data.id); if (note === undefined) { ctx.res.status = 404; return ctx; } ctx.res.json(note); return ctx; });

The code sets up an HTTP get request handler at the /notes/:id URL path, allowing users to get an individual note.

The endpoint takes two main inputs:

  1. A note ID that comes from the URL parameters (the :id part in /notes/:id)
  2. User authentication information that contains a user ID (userId).

Add another endpoint to the same file with the following code:

javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
notesApi.put("/notes/:id", async (ctx: HttpContextWithAuthentication) => { const { sub: userId } = ctx.authentication.token; const params = idSchema.safeParse(ctx.req.params); const input = updateNoteSchema.safeParse(ctx.req.json()); if (params.success == false) { ctx.res.status = 400; ctx.res.json(params.error.issues); return ctx; } if (input.success == false) { ctx.res.status = 400; ctx.res.json(input.error.issues); return ctx; } if (userId == null) { ctx.res.status = 400; return ctx; } const store = getNoteStore("get", "set"); const notes = await store.get(userId); const note = notes.notes.find((note) => note.id === params.data.id); const updatedNote = { ...note, ...input.data }; await store.set(userId, { notes: notes.notes.map((note) => note.id === params.data.id ? updatedNote : note ), }); ctx.res.json(updatedNote); return ctx; });

This code handles updating an existing note in a notes application. The code sets up an HTTP PUT request handler at the /notes/:id URL path, where ":id" represents the unique identifier of the note being updated.

For inputs, it takes:

  1. A note ID in the URL parameters Updated note data in the request body
  2. A user ID from the authentication token

The code produces either:

  1. The updated note data as JSON if successful
  2. Error messages with a 400 status code if something goes wrong.

Now let's round out the example with our application code.

Editing Notes in the Application

Extend the entity/notes/api/notes.ts file with the following code:

javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
export function useQueryNoteById(id: string) { const httpClient = useHttpClient(); const { isSessionLoading } = useSession(); return useQuery<Note>({ queryKey: [`notes-${id}`], queryFn: async () => { const response = await httpClient.get(`/api/notes/${id}`); return await response.json(); }, enabled: !isSessionLoading, }); }

This hook is designed to fetch a single note from an API based on its ID. Think of it like looking up a specific page in a notebook using a page number.

The function takes one input: an id parameter of type string, which is used to identify which specific note we want to retrieve from the server.

The output of this function is a result that contains a single Note object.

Next, add another hook to edit an existing note with the following code.

javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
export type EditNoteParams = { id: string; title: string; content: string; }; export function useMutationEditNote(options?: { onSuccess?: () => void; onError?: (error: Error) => void; }) { const httpClient = useHttpClient(); const queryClient = useQueryClient(); const mutation = useMutation({ mutationFn: async (note: EditNoteParams) => { const response = await httpClient.put(`/api/notes/${note.id}`, { json: note, }); return await response.json(); }, onSuccess: () => { options?.onSuccess?.(); queryClient.invalidateQueries({ queryKey: ["notes"] }); }, onError: options?.onError, }); return mutation; }

This hook creates a way to edit existing notes in a note-taking application.

The function takes an optional options parameter that can include two callback functions: 1.onSuccess: runs when the edit is successful 2. onError: runs if something goes wrong during the edit.

When used, this function returns a mutation object that can be used to trigger the note-editing process. The mutation expects to receive an EditNoteParams object containing three pieces of information: the note's ID, title, and content.

Next, create a page in pages/note/note-edit called note-edit-page.css.ts and add the following code:

javascript
1
2
3
4
5
6
7
8
import { style } from "@vanilla-extract/css"; export const itemContent = style({ flex: 1, display: "flex", justifyContent: "stretch", alignItems: "stretch", });

The code creates a style object called itemContent that defines how an element should be displayed on the page. It uses CSS Flexbox properties to control the layout. The style rules make the element:

  • Take up all available space in its container (flex: 1)
  • Display its contents using flexible box layout (display: "flex")
  • Stretch its content across the available width (justifyContent: "stretch")
  • Stretch its content across the available height (alignItems: "stretch")

We will use this css component to style the page for creating notes.

Next, create a page in pages/note called note-create-page.tsx and add the following code (make sure to resolve all the dependencies):

javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
export default function NoteEditPage() { const { id } = useParams<{ id: string }>(); const { data: note, isPending, isError, error, isSuccess, } = useQueryNoteById(id); return ( <IonPage> <IonHeader> <IonToolbar> <IonButtons slot="start"> <IonBackButton /> </IonButtons> <IonTitle>Edit note</IonTitle> </IonToolbar> </IonHeader> <IonContent className="ion-padding"> {isPending && <NoteEditPagePending />} {isError && <NoteEditPageError error={error} />} {isSuccess && <NoteEditPageSuccess note={note!} />} </IonContent> </IonPage> ); }

This component serves as the edit page where users edit an existing note.

The component takes in an id parameter to query for the note using the useQueryNoteById hook:

It produces a page layout that shows:

  • A header with the title "Edit note"
  • A button for navigating back
  • The main content area that displays either:
    • A loading skeleton while the note are being fetched
    • An error message if something goes wrong
    • The edit note form when successfully loaded
Building your own app? Get early access to our Livestream or Video Calling API and launch in days!

The components for the main content have not been created, so let's do that now, starting with the following code and importing any missing dependencies:

javascript
1
2
3
4
5
6
7
8
function NoteEditPageError({ error }: { error: Error }) { return ( <> <IonIcon icon={alert} size="large"></IonIcon> <IonText>{error.message}</IonText> </> ); }

This widget will display an error message to the user.

Next add the NoteEditPagePending component:

javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
function NoteEditPagePending() { return ( <IonList> <IonListHeader> <IonSkeletonText animated={true} style={{ width: "80px" }} ></IonSkeletonText> </IonListHeader> <IonItem lines="none"> <IonLabel> <h3> <IonSkeletonText animated={true} style={{ width: "80%" }} ></IonSkeletonText> </h3> <p> <IonSkeletonText animated={true} style={{ width: "60%" }} ></IonSkeletonText> </p> <p> <IonSkeletonText animated={true} style={{ width: "30%" }} ></IonSkeletonText> </p> </IonLabel> </IonItem> </IonList> ); }

This component will display a loading skeleton animation mirroring the note form.

Finally, add the NoteEditPageSuccess component with the following code:

javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
function NoteEditPageSuccess({ note }: { note: Note }) { const updateNote = useMutationEditNote(); const router = useIonRouter(); const form = useForm({ defaultValues: { id: note.id, title: note.title, content: note.content, }, onSubmit: async ({ value }) => { await updateNote.mutateAsync(value); router.goBack(); }, }); return ( <> <form className={vstack} style={assignInlineVars({ [gap]: "8px", })} onSubmit={(e) => { e.preventDefault(); e.stopPropagation(); form.handleSubmit(); }} > <form.Field name="title" validators={{ onChange: ({ value }) => { console.log(value); console.log(value.length === 0); return value.length === 0 ? "Required" : undefined; }, }} children={(field) => { return ( <IonItem lines="none"> <IonInput id={field.name} name={field.name} className={clsx({ "ion-invalid": field.state.meta?.errors.length > 0, "ion-touched": field.state.meta.isTouched, })} type="text" label="Title" labelPlacement="stacked" placeholder="Add your note title" errorText={field.state.meta?.errors?.join(", ") ?? ""} value={field.state.value} onIonInput={(event) => field.handleChange(event.target.value?.toString() ?? "") } ></IonInput> </IonItem> ); }} ></form.Field> <form.Field name="content" validators={{ onChange: ({ value }) => { console.log(value); console.log(value.length === 0); return value.length === 0 ? "Required" : undefined; }, }} children={(field) => { return ( <IonItem lines="none" className={itemContent}> <IonTextarea id={field.name} name={field.name} className={clsx({ "ion-invalid": field.state.meta?.errors.length > 0, "ion-touched": field.state.meta.isTouched, })} rows={40} label="Content" labelPlacement="stacked" autoGrow placeholder="Add your note content" errorText={field.state.meta?.errors?.join(", ") ?? ""} value={field.state.value} onIonInput={(event) => field.handleChange(event.target.value ?? "") } ></IonTextarea> </IonItem> ); }} ></form.Field> <IonRow> <IonCol> <IonButton expand="block" type="submit" disabled={updateNote.isPending} > Update Note </IonButton> </IonCol> </IonRow> </form> </> ); }

This code creates a form component that allows users to edit an existing note.

The component takes one input: a note object that contains the existing note's id, title, and content.

Using the useForm hook from @tanstack/react-form, the component will validate the fields on submission and execute updateNote from the useMutationEditNote mutation. If the mutation succeeds, the page will navigate back to the previous screen.

To wire up this screen, open router.tsx and add the following route code after the /notes/create route:

\\</Route>

This code creates a route that can be accessed from /notes/:id/d in the browser.

Upon running the newly updated application, click on the "Edit note" button on a previously created note and you should see the new screen:

Edit the note title or content and submit the note. You should see the updated note once you are redirected to the dashboard.

Deleting Notes

Adding the API Request

In the notes service, add the following code to the bottom of the file.

javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
notesApi.delete("/notes/:id", async (ctx: HttpContextWithAuthentication) => { const { sub: userId } = ctx.authentication.token; const params = idSchema.safeParse(ctx.req.params); if (params.success == false) { ctx.res.status = 400; ctx.res.json(params.error.issues); return ctx; } if (userId == null) { ctx.res.status = 400; return ctx; } const store = getNoteStore("get", "set"); const notes = await store.get(userId); const deletedNote = notes.notes.find((note) => note.id === params.data.id); if (deletedNote === undefined) { ctx.res.status = 404; return ctx; } await store.set(userId, { notes: notes.notes.filter((note) => note.id !== params.data.id), }); return ctx; });

This endpoint allows users to delete one of their notes by providing the note's ID. It ensures only authorized users can delete their own notes and handles various error cases appropriately.

The handler takes two main inputs:

  1. The note id in the request url path, which is parsed as JSON and validated against the idSchema.
  2. A user ID that is parsed from the authentication token, ensuring logged-in users can delete their notes.

The output is either an empty response with a 200 status code (on success), 400 status for invalid parameters or missing user, or a 404 status if the note doesn't exist.

Now let's move on to building the application UI.

Deleting Notes in the Application

First, let's create a hook to create a mutation to delete a note. Add the following code to the notes.ts file in entities/note/api:

javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
export function useMutationDeleteNote() { const httpClient = useHttpClient(); const queryClient = useQueryClient(); const mutation = useMutation({ mutationFn: async (id: string) => { const response = await httpClient.delete(`/api/notes/${id}`); return response.status; }, onSuccess: () => { console.log("lets go"); queryClient.invalidateQueries({ queryKey: ["notes"] }); }, }); return mutation; }

This function creates a way to delete notes from the server and update the user interface accordingly.

When used, it creates a mutation (a function that changes data) that can delete a note through these steps:

  1. It accepts a note ID as a parameter when triggered
  2. It sends a DELETE request to the server at /api/notes/{id} using the HTTP client
  3. It returns the status of the deletion operation
  4. When successful, it tells the query client to refresh the list of notes.

Next, let's use the newly created mutation. Inside of note-list-card.tsx add the following code to the beginning of the NoteListCard component:

javascript
1
const mutation = useMutationDeleteNote();

Incorporate this mutation by extending the IonCard component with the following components.

javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<IonButton id={`present-alert-${note.id}`} fill="clear"> Delete Note </IonButton> <IonAlert trigger={`present-alert-${note.id}`} header="Delete note" message="Are you sure you want to delete this note?" buttons={[ { text: "CANCEL", role: "cancel", handler: () => { console.log("Alert canceled"); }, }, { text: "DELETE NOTE", role: "confirm", htmlAttributes: { disabled: mutation.isPending }, handler: async () => { await mutation.mutateAsync(note.id); }, }, ]} ></IonAlert>

This code adds a button to the NoteListCard with the text "Delete Note." When a user clicks the button, an alert will be displayed confirming that the user wants to delete the note. Finally, if the user confirms deletion, the mutation is executed.

Upon updating the application, you should see the deletion button.

If you click the deletion button, the new confirmation dialog.

Finally, upon clicking the confirmation button, our note should be deleted.

Conclusion

In this article, we extended our existing notes API with endpoints for editing and deleting notes. Then, we extended the application with the ability for users to edit and delete notes.

In the next article, we will learn how to deploy the API to our cloud provider of choice using Nitric.

Integrating Video With Your App?
We've built a Video and Audio solution just for you. Check out our APIs and SDKs.
Learn more ->