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:
1export 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.
123456789101112131415161718192021222324252627282930313233notesApi.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:
- A note ID that comes from the URL parameters (the :id part in /notes/:id)
- User authentication information that contains a user ID (userId).
Add another endpoint to the same file with the following code:
1234567891011121314151617181920212223242526272829303132333435363738394041424344notesApi.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:
- A note ID in the URL parameters Updated note data in the request body
- A user ID from the authentication token
The code produces either:
- The updated note data as JSON if successful
- 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:
12345678910111213export 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.
12345678910111213141516171819202122232425262728293031export 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:
12345678import { 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):
1234567891011121314151617181920212223242526272829export 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
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:
12345678function 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:
12345678910111213141516171819202122232425262728293031323334function 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:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112function 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:
\
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.
123456789101112131415161718192021222324252627282930313233343536notesApi.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:
- The note id in the request url path, which is parsed as JSON and validated against the idSchema.
- 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:
123456789101112131415161718export 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:
- It accepts a note ID as a parameter when triggered
- It sends a DELETE request to the server at /api/notes/{id} using the HTTP client
- It returns the status of the deletion operation
- 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:
1const mutation = useMutationDeleteNote();
Incorporate this mutation by extending the IonCard component with the following components.
12345678910111213141516171819202122232425<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.