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.
⛔ Refactor replaces existing TransferOffers
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
.
⛔ deprecated let offerList
and offers 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.
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