Extend the Frontend

The backend is complete. However, the user needs a convenient way to interact with the model and endpoints. UI components in the form of buttons provide event listeners that activate the desired behavior in the backend. To accomplish this goal, begin by creating hooks to handle the API calls.

Step 1: Create the Hooks

Add two hooks. The first is a query hook, useRejectedTransferOffers, that reads the RejectedTransferOffers through a backend API endpoint. The second is a mutation hook, useRejectWithReason, which exercises the RejectWithReason choice through a frontend JSON API endpoint.

Begin with the query hook in the /app/frontend/src/hooks/queries subdirectory.

useRejectedTransferOffers.ts

1 import { UseQueryResult, useQuery } from '@tanstack/react-query';
2 import {
3   ListRejectedTransferOfferResponse,
4   TemplateContract as ApiTemplateContract,
5 } from 'app-read-api-ts-client';
6 import { RejectedTransferOffer as DamlRejectedTransferOffer } from '@daml.js/daml-app-template/lib/Com/Daml/App/Template/Model';
7 import TemplateContract from '../../utils/TemplateContract';
8 import useAuthenticatedReadApi from './useAuthenticatedReadApi';

This section imports necessary React query hooks, types from the API client, and Daml-generated types. Notably, the useAuthenticatedReadApi import handles authenticated backend API requests.

10 const useRejectedTransferOffers = (): UseQueryResult
11   TemplateContract<DamlRejectedTransferOffer>[],
12   string
13 > => {
14   const readApi = useAuthenticatedReadApi();
15
16   return useQuery({
17     queryKey: ['RejectedTransferOffers', readApi],
18     queryFn: async () => {
19       if (!readApi) throw new Error('Read API not available');
20       try {
21         const response: ListRejectedTransferOfferResponse =
22           await readApi.getRejectedTransferOffers();
23
24         const rejectedOffers = response.rejected_transfer_offers || [];
25
26         return rejectedOffers.map((rejectedOffer: ApiTemplateContract) =>
27           TemplateContract.fromOpenAPI(DamlRejectedTransferOffer, rejectedOffer),
28         );

The useRejectedTransferOffers hook definition fetches rejected transfer offers with React Query’s useQuery. queryFn calls readApi.getRejectedTransferOffers() to fetch data from the backend. It maps TemplateContract objects and converts the data from the API to the Daml format, then returns the rejected transfer offers’ data and status.

29       } catch (error: unknown) {
30         console.error('Error fetching rejected transfer offers:', error);
31         if (error instanceof Error) {
32           if ('status' in error && error.status === 501) {
33             throw new Error('The rejected transfer offers feature is not implemented yet.');
34           }
35           throw error;
36         }
37         throw new Error('Failed to fetch rejected transfer offers.');
38       }
39     },
40     enabled: !!readApi,
41   });
42 };
43
44 export default useRejectWithReason;

This block handles error cases when fetching rejected transfer offers. It logs errors, checks for a 501 status, and throws appropriate error messages. The enabled option ensures the query only runs when readApi is available.

Next, write the mutation hook in /app/frontend/src/hooks/mutations.

Mutation hooks differ from query hooks in that they connect directly to frontend JSON API endpoints. This mutation updates the Daml ledger via interaction with the RejectWithReason choice created in Model.daml.

useRejectWithReason.ts

1 import { UseMutationResult, useMutation, useQueryClient } from '@tanstack/react-query';
2 import { TransferOffer } from '@daml.js/daml-app-template/lib/Com/Daml/App/Template/Model';
3 import { ContractId } from '@daml/types';
4 import useLedgerApiClient from '../queries/useLedgerApiClient';

The useLedgerApiClient import calls the query hook by the same name to access the Daml ledger API client. The client enables the React hook to update the Daml ledger state - in this event, to exercise the RejectWithReason choice in the Daml model.

 6 type RejectWithReasonInput = {
 7   transferOfferId: ContractId<TransferOffer>;
 8   reason: string;
 9 };
10
11 type RejectWithReasonResult = {
12   transferOfferId: ContractId<TransferOffer>;
13   reason: string;
14 };

Two TypeScript types, RejectWithReasonInput and RejectWithReasonResult, are created in this block. The input and result mirror one another in that they both include a ContractId of a TransferOffer and a reason. This structure ensures type safety in the useRejectWithReason hook.

16 const useRejectWithReason = (): UseMutationResult
17   RejectWithReasonResult,
18   Error,
19   RejectWithReasonInput
20 > => {
21   const ledgerApi = useLedgerApiClient();
22   const queryClient = useQueryClient();

This block defines the mutation hook. It specifies RejectWithReasonResult as the successful mutation result, Error for error handling, and RejectWithReasonInput as the input type. The hook initializes the ledger API and query clients to manage API interactions.

24   return useMutation<RejectWithReasonResult, Error, RejectWithReasonInput>({
25     mutationFn: async ({ transferOfferId, reason }) => {
26       console.log('Rejecting offer:', transferOfferId, 'with reason:', reason);
27       await ledgerApi.RejectWithReason(transferOfferId, reason);
28       console.log('Offer rejected successfully.');
29       return { transferOfferId, reason };
30     },

useMutation handles useRejectWithReason’s rejection process. The mutationFn logs the rejection attempt. Then the Daml choice, RejectWithReason, is exercised via the ledger API. RejectWithReason takes the transferOfferId and reason as parameters, logs the successful rejection, and returns the result.

31     onSuccess: async (data) => {
32       console.log('Rejection successful, invalidating queries');
33       try {
34         await queryClient.invalidateQueries(['ListTransferOffers']);
35         await queryClient.invalidateQueries(['ListRejectedTransferOffers']);
36         console.log('Queries invalidated successfully');
37       } catch (error) {
38         console.error('Failed to invalidate queries:', error);
39       }
40       return data;
41     },
42   });
43 };
44
45 export default useRejectWithReason;

The final block of the mutation hook handles post-rejection tasks. It logs the successful rejection, invalidates the queries so they cannot be made again, and ensures the UI reflects the updated state after rejection.

Step 2: Update LedgerApiClient.ts

Next, implement a RejectWithReason method in LedgerApiClient.ts to exercise a choice directly on the Daml ledger.

Locate LedgerApiClient.ts in /app/frontend/src/utils/.

Add these methods near the end of the file, after the asynchronous acceptTransferOffer method.

115 async RejectWithReason(
116   transferOfferCid: ContractId<TransferOffer>,
117   reason: string,
118 ): Promise<void> {
119   await this.ledger.exercise(TransferOffer.RejectWithReason, transferOfferCid, { reason });
120 }

Defines an asynchronous method that takes a transfer offer contract ID and a reason as parameters and returns a Promise.

await this.ledger.exercise calls the exercise method on the Daml ledger client, which triggers the RejectWithReason choice on the targeted TransferOffer contract.

The RejectWithReason method is critical because it allows the frontend to communicate with the Daml ledger.

Step 3: Refactor TransferOffers.tsx

The next step is to wire up the frontend components in TransferOffers.tsx. To complete this objective we must:

  • Add the import statements
  • Implement the rejectWithReason hook as a constant and create a rejection handler
  • Refactor the IncomingTransferOffer UI Rendering
  • Add a Dialog element
  • Create the rejected transfer offers table
  • Refactor the TransferOffers functional component

Add the import statements

Add useEffect and useCallback aliases to the react module import. Next, include the Dialog-related aliases to the mui/material module import. Add the new import statements under the useAcceptTransferOffer import statement in /components/TransferOffers.tsx.

 1 import { useMemo, useState, useEffect, useCallback } from 'react';
 2 import {
 3   Button,
 4   Card,
 5   Dialog,
 6   DialogActions,
 7   DialogContent,
 8   DialogTitle,
 9   MenuItem,
10   Stack,
11   Table,
12   TableBody,
13   TableCell,
14   TableHead,
15   TableRow,
16   TextField,
17   Typography,
18 } from '@mui/material';
28 import useRejectWithReason from '../hooks/mutations/useRejectWithReason';
33 import useRejectedTransferOffers from '../hooks/queries/useRejectedTransferOffers';

Implement the rejectWithReason hook as a constant and create a rejection handler

In the same file, add error handling to the existing IncomingTransferOffer component.

203 const IncomingTransferOffer = ({
204   offer,
205   onRejected,
206 }: {
207   offer: TemplateContract<TransferOffer>;
208   onRejected: (offerId: ContractId<TransferOffer>) => void;
209 }) => {

The IncomingTransferOffer component now accepts two properties, offer and onRejected. onRejected has been added to improve error handling in the component.

Next, initialize new state variables and the mutation hook directly beneath the existing constants in the IncomingTransferOffer component.

222 const [isRejectDialogOpen, setIsRejectDialogOpen] = useState(false);
223 const [rejectionReason, setRejectionReason] = useState('');
224 const [isRejectClicked, setIsRejectClicked] = useState(false);
225 const rejectWithReason = useRejectWithReason();

The state variables control the rejection dialog visibility, store the rejection reason, and track if the reject button has been clicked. The hook mutation executes transfer offer rejections.

Immediately after the const expressions, create a handler function that triggers when the user initiates a rejection.

227 const handleRejectClick = () => {
228   setIsRejectDialogOpen(true);
229   setIsRejectClicked(true);
230 };

The handleRejectClick handler opens a rejection dialog by setting isRejectDialogOpen to true and indicates that the ‘reject’ button has been clicked by setting isRejectClicked to true.

Follow the handler with a callback function to execute the transfer offer rejection.

232 const handleRejectConfirm = useCallback(() => {
233   console.log('Attempting to reject transfer offer:', offer.contractId);
234   rejectWithReason.mutate(
235     { transferOfferId: offer.contractId, reason: rejectionReason },
236     {
237       onSuccess: (data) => {
238         setIsRejectDialogOpen(false);
239         setRejectionReason('');
240         onRejected(data.transferOfferId);
241       },
242     },
243   );
244 }, [rejectWithReason, offer.contractId, rejectionReason, onRejected]);

handleRejectConfirm logs the rejection attempt and calls the rejectWithReason hook mutation with the transfer offer ID and reason as parameters. On a successful attempt, the dialog is closed, the reason cleared, and the onRejected callback is triggered.

Next, the state of isRejectedClicked and the hook mutation state are logged in the browser console.

246 console.log('Component rendering. isRejectClicked:', isRejectClicked);
247 console.log('Mutation state:', rejectWithReason);

Refactor the IncomingTransferOffer UI

Next, refactor the IncomingTransferOffer UI rendering to introduce the new rejection functionality.

Note

The JSX is extensive. Updates to the existing code are highlighted. Revisions to the JSX format have been made for enhanced readability, but are not otherwise marked.

249   return (
250     <>
251     <TableRow aria-label="incoming transfer">
252       <TableCell aria-labelledby="incoming-transferoffer-sender">
253         {offer.payload.sender}
254       </TableCell>
255       <TableCell aria-labelledby="incoming-transferoffer-amount">
256         {renderedFungible.amount}
257       </TableCell>
258       <TableCell aria-labelledby="incoming-transferoffer-instrument-issuer">
259         {renderedFungible.instrumentIssuer}
260       </TableCell>
261       <TableCell aria-labelledby="incoming-transferoffer-instrument-id">
262         {renderedFungible.instrumentId}
263       </TableCell>
264       <TableCell aria-labelledby="incoming-transferoffer-instrument-version">
265         {renderedFungible.instrumentVersion}
266       </TableCell>
267       <TableCell>
268         <TextField
269           select
270           label="Receiver Account"
271           value={receiverAccountCid || ''}
272           onChange={(e) => setReceiverAccountCid(e.target.value as ContractId<Account>)}
273         >
274           {(accounts.data || []).map((account) => (
275             <MenuItem key={account.contractId} value={account.contractId}>
276               {account.view(Account).id.unpack}
277             </MenuItem>
278           ))}
279         </TextField>
280       </TableCell>
281       <TableCell>
282         {!isRejectClicked ? (
283           <>
284         <Button
285           onClick={() =>
286             acceptTransferOffer.mutate({ receiverAccountCid: receiverAccountCid! })
287           }
288           disabled={!receiverAccountCid || acceptTransferOffer.isLoading}
289         >
290           Accept
291         </Button>
292         <Button onClick={handleRejectClick} disabled={rejectWithReason.isLoading}>
293           Reject
294         </Button>
295       </>
296     ) : null}
297     {acceptTransferOffer.isError && (
298       <ErrorDisplay
299         message={`Failed to accept transfer offer ${acceptTransferOffer.error}`}
300       />
301     )}
302     {rejectWithReason.isError && (
303       <ErrorDisplay
304         message={`Failed to confirm reject transfer offer: ${
305           rejectWithReason.error instanceof Error
306             ? rejectWithReason.error.message
307             : String(rejectWithReason.error)
308         }`}
309       />
310       )}
311       </TableCell>
312     </TableRow>
313   </>

This refactor of the IncomingTransferOffer JSX introduces the rejection functionality to the UI.

The outer structure of the UI rendering has changed from a single <TableRow> to a fragment <> for greater flexibility in how the UI is rendered.

The Accept button has been wrapped in a conditional render {!isRejectClicked ? (...) : null} that controls the visibility of the Accept and Reject buttons to reduce the opportunity for conflicting actions on the Daml ledger.

Finally, a new Reject button has been added.

Add a Dialog element

Next, add a <Dialog> element to aid in the rejection of a transfer offer. Include this element directly below the closing “incoming transfer” </TableRow> element.

313   <Dialog open={isRejectDialogOpen} onClose={() => setIsRejectDialogOpen(false)}>
314     <DialogTitle>Reject Transfer Offer</DialogTitle>
315     <DialogContent>
316       <TextField
317         autoFocus
318         margin="dense"
319         label="Rejection Reason"
320         type="text"
321         fullWidth
322         value={rejectionReason}
323         onChange={(e) => setRejectionReason(e.target.value)}
324       />
325     </DialogContent>
326     <DialogActions>
327       <Button onClick={() => setIsRejectDialogOpen(false)}>Cancel</Button>
328       <Button
329         onClick={handleRejectConfirm}
330         disabled={!rejectionReason || rejectWithReason.isLoading}
331       >
332         Confirm Rejection
333       </Button>
334     </DialogActions>
335   </Dialog>
336 </>

The <Dialog> element creates a modal pop-up for rejecting a transfer offer. It includes a text input for the rejection reason, as well as cancel and confirm buttons. Using a Dialog simplifies the user’s process of rejecting a transfer offer by providing a dedicated user interface.

Create the rejected transfer offers table

The transfer offer dialog modal is followed by the rejected offers table. This table displays the user’s rejected transfer offers and the reason for each rejection.

340 const RejectedTransferOffersTable = () => {
341   const rejectedOffers = useRejectedTransferOffers();
342
343   if (rejectedOffers.isLoading) return <Loading />;
344   if (rejectedOffers.isError) {
345     console.error('Error loading rejected offers:', rejectedOffers.error);
346     return (
347       <ErrorDisplay message="Failed to load rejected offers. The feature may not be implemented yet." />
348     );
349   }
350
351   if (!rejectedOffers.data || rejectedOffers.data.length === 0) {
352     return <Typography>No rejected transfer offers found.</Typography>;
353   }
354
355   return (
356     <Table>
357       <TableHead>
358         <TableRow>
359           <TableCell>Sender</TableCell>
360           <TableCell>Receiver</TableCell>
361           <TableCell>Rejection Reason</TableCell>
362         </TableRow>
363       </TableHead>
364       <TableBody>
365         {rejectedOffers.data.map((offer) => (
366           <TableRow key={offer.contractId}>
367             <TableCell>{offer.payload.transferOffer.sender}</TableCell>
368             <TableCell>{offer.payload.transferOffer.receiver}</TableCell>
369             <TableCell>{offer.payload.rejectionReason}</TableCell>
370           </TableRow>
371         ))}
372       </TableBody>
373     </Table>
374   );
375 };

RejectedTransferOffersTable calls the useRejectedTransferOffers query hook to fetch data from rejected transfer offers and displays its sender, receiver, and reason for the rejection. rejectedOffers.isError gracefully handles error states, and the rejectedOffers map returns requested information to the table.

Refactor the TransferOffers Functional Component

The TransferOffers component is responsible for managing transfer states and displaying transfer offers to each user. The component must handle incoming, outgoing, and rejected offers and render the user interface so users may interact with their offers.

Refactor the existing TransferOffers component as a functional component to ensure type safety.

377 const TransferOffers: React.FC = () => {
378   const party = usePrimaryParty();
379   const offers = useTransferOffers();
380   const [activeOffers, setActiveOffers] = useState<TemplateContract<TransferOffer>[]>([]);

The TransferOffers functional component opens by assigning the usePrimaryParty and useTransferOffers hooks to constants. These hooks assist in fetching data necessary for transfer offers.

The new line, const [activeOffers, setActiveOffers], calls useState to manage the state of active offers. Together, the initial expressions allow the component to responsively handle and display transfer offer data.

✅ Refactored TransferOffers functional component.

New TransferOffers functional component

⛔ Refactor replaces existing TransferOffers component.

Old TransferOfers component

Create a custom hook, useEffect, to synchronize the local state of active offers

382 useEffect(() => {
383   if (offers.data) {
384     setActiveOffers(offers.data);
385   }
386 }, [offers.data]);

useEffect runs when offers.data changes, subsequently updating the activeOffers state with the latest information from the ledger. The hooks maintain accurate transfer offer information and consistency between the local state and the ledger data.

Implement a handler to remove rejected offers

388 const handleRejectedOffer = useCallback((rejectedOfferId: ContractId<TransferOffer>) => {
389   console.log('Handling rejected offer:', rejectedOfferId);
390   setActiveOffers((prevOffers) => {
391     const newOffers = prevOffers.filter((offer) => offer.contractId !== rejectedOfferId);
392     console.log('New active offers:', newOffers);
393     return newOffers;
394   });
395 }, []);

The handleRejectedOffer callback takes a rejected offer’s ID as a parameter, updates the activeOffers state by filtering the rejected offer, and returns a new list of active offers. This enables the display of the current statuses of transfer offers without requiring a new data fetch from the ledger.

Render a list of transfer offers

Follow the rejected offer handler callback with a function to render a list of transfer offers. This block replaces the existing let offerList and subsequent if/else pattern for displaying offer list data.

397 const renderOfferList = () => {
398   if (offers.isLoading) return <Loading />;
399   if (offers.isError)
400     return <ErrorDisplay message={`Failed to load transfer offers: ${offers.error}`} />;
401
402   return (
403     <Stack direction="column" spacing={2}>

renderOfferList manages the display of transfer offers per the state of data stored in offers. First, the function checks if the offers are loading and shows a loading indicator if true. Next, it checks for errors and displays an error message if any occur while fetching offers.

The return statement begins the JSX to display properly loaded transfer offers.

const renderOfferList replaces the existing structure under let offerList.

New renderOfferList function

⛔ deprecated let offerList and offers if/else logic.

Deprecated offerList if/else logic

In the following JSX, replace offers.data with activeOffers. Both instances are located within TableBody elements nested inside the “Outgoing” and “Incoming” Tables.

417 <TableBody>
418   {activeOffers
419     .filter((o) => o.payload.sender === party.data)
420     .map((o) => (
421       <OutgoingTransferOffer key={o.contractId} offer={o} />
422     ))}
423 </TableBody>

The IncomingTransferOffer map within the “Incoming” table also needs to call the rejected offer handler.

439     <TableBody>
440       {activeOffers
441         .filter((o) => o.payload.receiver === party.data)
442         .map((o) => (
443           <IncomingTransferOffer
444             key={o.contractId}
445             offer={o}
446             onRejected={handleRejectedOffer}
447           />
448         ))}
449     </TableBody>
450     </Table>
451   </Stack>
452   );
453 };

Replace deprecated offerList with renderOfferList()

In the TransferOffers component’s final return statement, replace the deprecated offerList object with the new renderOfferList() function. Include the <RejectedTransferOffersTable /> element below the currently existing <TransferOfferRequestForm /> element.

455   return (
456     <Card variant="outlined">
457       <Typography variant="h4">Transfer Offers</Typography>
458       <TransferOfferRequestForm />
459       {renderOfferList()}
460       {offers.data && (
461         <>
462           <Typography variant="h5">Rejected Offers</Typography>
463           <RejectedTransferOffersTable />
464         </>
465       )}
466     </Card>
467   );
468 };

You have successfully extended the application to reject a transfer offer. Ensure the changes are saved, then rebuild the frontend.

Assemble the TypeScript frontend

./gradlew :app:frontend:assemble

This command assembles the TypeScript frontend. If successful, it outputs BUILD SUCCESSFUL.

Gradle frontend assemble success

Observe the new functionality by starting the Canton server

In the first terminal, begin the Canton server:

./scripts/start-canton.sh

In a second terminal, run the Daml tests followed by the backend tests:

./gradlew :app:daml:test
./gradlew :app:backend:test

Lint the codebase:

./gradlew spotlessCheck
./gradlew spotlessApply

Start the backend server:

./gradlew bootRun --args='--spring.profiles.active=dev'

In a third terminal, begin the frontend server:

cd app/frontend
npm run dev

In a fourth terminal, from the frontend subdirectory, begin the provider’s frontend server:

JSON_API_PORT=4003 npm run dev