Creating admin-like web applications with NestJS and React Admin. Part 2.

Introduction

In the first part, we showed how React Admin and NestJS can work together to quickly create a simple admin panel.

In this part, we will go a little bit beyond basic CRUD and cover the following topics:

  1. Handling one-to-many relationships
  2. Handling file uploads to Amazon S3
  3. Using the Google Maps input

Handling one-to-many relationships

To showcase this (and other points), we will change the domain model of our application. So instead of a guest list, we will manage companies and their addresses.

This will require the following entities and controllers to added to our API:

  • Company Entity with a declared relationship with Address Entity:
import {
Entity,
Column,
PrimaryGeneratedColumn,
BaseEntity,
OneToMany,
} from 'typeorm';
import { Type } from 'class-transformer';
import { AddressEntity } from '../address/address.entity';@Entity({ name: 'company' })
export class CompanyEntity extends BaseEntity {
@PrimaryGeneratedColumn()
id: number; @Column()
name: string; @Column()
description: string; @Column({
type: 'json',
comment: `[{ name: "string", url: "string"}]`,
nullable: true,
})
cover: Array<{ name: string, url: string }>; @OneToMany((type) => AddressEntity, (address) => address.company)
@Type((t) => AddressEntity)
addresses: AddressEntity[];

}
  • Address Entity:
import {
Entity,
Column,
PrimaryGeneratedColumn,
ManyToOne,
BaseEntity,
} from 'typeorm';
import { CompanyEntity } from '../company/company.entity';@Entity({ name: 'address' })
export class AddressEntity extends BaseEntity {
@PrimaryGeneratedColumn()
id: number; @Column()
name: string; @Column()
city: string; @Column()
street: string; @Column()
zip: string; @Column()
notes: string; @Column({ nullable: true })
companyId: number;
@ManyToOne(
(type) => CompanyEntity,
(company) => company.addresses
)
company: CompanyEntity;

}

The controllers for these entities are created using @nestjsx/crud. You can see how this can be done in the previous article.

From the admin UI perspective, we have to add new resources:

import React from 'react';
import { Admin, Resource } from 'react-admin';
import crudProvider from '@fusionworks/ra-data-nest-crud';
import companies from './Companies';
import addresses from './Addresses';
import { url } from './config/connection';const dataProvider = crudProvider(url);const App = () => (
<Admin dataProvider={dataProvider}>
<Resource name="company" {...companies} />
<Resource name="address" {...addresses} />
</Admin>
);export default App;

Add a file with views for address entity:

import React from 'react';
import {
Create,
SimpleForm,
TextInput,
Edit,
required,
TabbedShowLayout,
Tab,
TextField,
Show,
ReferenceField,
List,
Datagrid,
ReferenceInput,
SelectInput,
} from 'react-admin';const validateRequired = required();export const AddressCreate = props => {
const { location } = props;
const { companyId } = parse(location.search);
const redirect = companyId ? `/company/${companyId}/show/addresses`: false; return (
<Create {...props}>
<SimpleForm redirect={redirect}>
<TextInput source="name" validate={validateRequired} />
<TextInput source="city" validate={validateRequired} />
<TextInput source="street" validate={validateRequired} />
<TextInput source="zip" validate={validateRequired} />
<TextInput source="notes" validate={validateRequired} />
{companyId ? null : (
<ReferenceInput source="companyId" reference="company">
<SelectInput optionText="name" optionValue="id" />
</ReferenceInput>
)}
</SimpleForm>
</Create>
);
};const AddressTitle = ({ record }) => (<span>{`${record.name}`}</span>);export const AddressEdit = props => (
<Edit {...props} title={<AddressTitle />}>
<SimpleForm redirect={(basePath, id, data) =>
`/company/${data.companyId}/show/addresses`}>
<TextInput source="name" validate={validateRequired} />
<TextInput source="city" validate={validateRequired} />
<TextInput source="street" validate={validateRequired} />
<TextInput source="zip" validate={validateRequired} />
<TextInput source="notes" validate={validateRequired} />
</SimpleForm>
</Edit>
);export const AddressList = props => (
<List {...props}>
<Datagrid rowClick="show">
<TextField source="name" />
<TextField source="city" />
<TextField source="street" />
<TextField source="zip" />
<TextField source="notes" />
<ReferenceField
source="companyId"
reference="company"
linkType="show"
>
<TextField source="name" />
</ReferenceField>
</Datagrid>
</List>
);export const AddressShow = props => (
<Show {...props}>
<TabbedShowLayout>
<Tab label="resources.summary.name">
<TextField source="name" />
<TextField source="city" />
<TextField source="street" />
<TextField source="zip" />
<TextField source="notes" />
<ReferenceField
source="companyId"
reference="company"
linkType="show"
>
<TextField source="name" />
</ReferenceField>
</Tab>
</TabbedShowLayout>
</Show>
);export default {
list: AddressList,
create: AddressCreate,
edit: AddressEdit,
show: AddressShow,
};

Convert the company view form to a tabbed interface and add a control for displaying existing addresses to the second tab:

import React from 'react';
import randomstring from 'randomstring';
import { S3FileInput, S3FileField } from '@fusionworks/ra-s3-input';
import {
TabbedShowLayout,
Tab,
List,
Create,
SimpleForm,
Edit,
Show,
ReferenceManyField,
Datagrid,
EditButton,
TextField,
TextInput,
required,
} from 'react-admin';
import { url } from '../config/connection';
import AddAddressButton from './AddAddressButton';const validateRequired = required();const CompanyCreate = (props) => (
<Create {...props}>
<SimpleForm redirect="show">
<TextInput source="name" />
<TextInput source="description" />
</SimpleForm>
</Create>
);const CompanyTitle = ({ record }) => (<span>{record.name}</span>);const CompanyEdit = props => (
<Edit {...props} title={<CompanyTitle />}>
<SimpleForm redirect="list">
<TextInput source="name" />
<TextInput source="description" />
</SimpleForm>
</Edit>
);const CompanyShow = props => (
<Show {...props} title={<CompanyTitle />}>
<TabbedShowLayout>
<Tab label="summary">
<TextField source="name" />
<TextField source="description" />
</Tab>
<Tab label="addresses" path="addresses">
<ReferenceManyField reference="address" target="companyId" addLabel={false}>
<Datagrid>
<TextField source="name" />
<TextField source="city" />
<TextField source="street" />
<TextField source="notes" />
<EditButton />
</Datagrid>
</ReferenceManyField>
<AddAddressButton />
</Tab>
</TabbedShowLayout>
</Show>
);const CompanyDescription = ({ record }) => {
return <div>{record.description}</div>;
};
const CompanyList = props => (
<List {...props}>
<Datagrid rowClick="show" expand={<CompanyDescription />}>
<TextField source="name" />
</Datagrid>
</List>
);export default {
list: CompanyList,
create: CompanyCreate,
edit: CompanyEdit,
show: CompanyShow,
};

And finally, add an “Add address” button which will open the create address form:

import React from 'react';
import { Link } from 'react-router-dom';
import { withStyles } from '@material-ui/core/styles';
import { Button } from 'react-admin';const styles = {
button: {
marginTop: '1em',
},
};const AddAddressButton = ({ classes, record }) => (
<Button
className={classes.button}
variant="raised"
component={Link}
to={`/address/create?companyId=${record.id}`}
label="Add an address"
title="Add an address"
/>
);export default withStyles(styles)(AddAddressButton);

Handling file uploads to Amazon S3

Let’s say we would like to add some photos to our company record. We will store them on Amazon S3. To handle it from the UI perspective, we created a custom React Admin input — https://github.com/FusionWorks/react-admin-s3-file-upload/. Here is how it should be added to our application:

import randomstring from 'randomstring';
import { S3FileInput, S3FileField } from '@fusionworks/ra-s3-input';
import { url } from '../config/connection';
...
const CompanyCreate = (props) => (
<Create {...props}>
<SimpleForm redirect="show">
<TextInput source="name" />
<TextInput source="description" />
<S3FileInput
apiRoot={url}
source="cover"
validate={validateRequired}
uploadOptions={{
signingUrl: `${url}/s3/sign`,
s3path: `NestJsAdminBoilerplate/${randomstring.generate(10)}`,
multiple: true,
accept: 'image/*',
}}
multipleFiles
/>

</SimpleForm>
</Create>
);const CompanyTitle = ({ record }) => (<span>{record.name}</span>);const CompanyEdit = props => (
<Edit {...props} title={<CompanyTitle />}>
<SimpleForm redirect="list">
<TextInput source="name" />
<TextInput source="description" />
<S3FileInput
apiRoot={url}
source="cover"
validate={validateRequired}
uploadOptions={{
signingUrl: `${url}/s3/sign`,
s3path: `NestJsAdminBoilerplate/${randomstring.generate(10)}`,
multiple: true,
accept: 'image/*',
}}
multipleFiles
/>

</SimpleForm>
</Edit>
);const CompanyShow = props => (
<Show {...props} title={<CompanyTitle />}>
<TabbedShowLayout>
<Tab label="summary">
<TextField source="name" />
<TextField source="description" />
<S3FileField apiRoot={url} source="cover" />
</Tab>
<Tab label="addresses" path="addresses">
....

</Tab>
</TabbedShowLayout>
</Show>
);...

To make this component work, we will need some support from the API side for signing AWS API requests. Let’s add the required components to our NestJS-based backend.

Service:

import { Injectable } from '@nestjs/common';
import { InjectConfig, ConfigService } from 'nestjs-config';
import { S3 } from 'aws-sdk';
import { Request, Response } from 'express';
@Injectable()
export class S3Service {
private readonly s3Bucket: string;
private readonly s3Region: string;
private s3: S3;

constructor(
@InjectConfig() private config: ConfigService,
) {
this.s3Bucket = config.get('s3.bucket');
this.s3Region = config.get('s3.region');
this.s3 = new S3({ region: this.s3Region, signatureVersion: 'v4' });
}

async signIn(req: Request, res: Response): Promise<any> {
const { objectName, contentType, path = '' } = req.query;
const objectNameChunks = objectName.split('/');
const filename = objectNameChunks[objectNameChunks.length - 1];
const mimeType = contentType;
const fileKey = `${path}/${objectName}`;
const params = {
Bucket: this.s3Bucket,
Key: fileKey,
Expires: 60,
ContentType: mimeType,
ACL: 'private',
};

res.set({ 'Access-Control-Allow-Origin': '*' });

this.s3.getSignedUrl('putObject', params, (err, data) => {
if (err) {
res.statusMessage = 'Cannot create S3 signed URL';

return res.status(500);
}

res.json({
signedUrl: data,
publicUrl: '/s3/uploads/' + fileKey,
filename,
fileKey,
});
});
}

tempRedirect(id: string, key: string, res: Response) {
const params = {
Bucket: this.s3Bucket,
Key: `NestJsAdminBoilerplate/${id}/${key}`,
};

this.s3.getSignedUrl('getObject', params, (err, url) => {
res.redirect(url);
});
}

}

Controller:

import { Controller, Get, Req, Res, Param } from '@nestjs/common';
import { Request, Response } from 'express';
import { S3Service } from './s3.service';
@Controller('s3')
export class S3Controller {
constructor(private s3service: S3Service) { }

@Get('/sign')
sign(@Req() req: Request, @Res() res: Response) {
return this.s3service.signIn(req, res);
}

@Get('/uploads/NestJsAdminBoilerplate/:id/:key')
fileRedirect(@Param('id') id: string, @Param('key') key: string, @Res() res: Response) {
return this.s3service.tempRedirect(id, key, res);
}
}

And let’s add the necessary environment variables to .env:

# ...
AWS_ACCESS_KEY_ID = <your aws access key>
AWS_SECRET_ACCESS_KEY = 'your aws secret key'
AWS_BUCKET_NAME = 'your bucket'
AWS_BUCKET_REGION = 'your region'
# ...

And use these variables in the config file for S3:

export default {
region: process.env.AWS_BUCKET_REGION,
bucket: process.env.AWS_BUCKET_NAME,
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
};

And we’re done with uploading images.

Using Google Maps input

Sometimes we need to associate some location on the map with one of our entities. For this purpose, we created a Google Maps input —https://github.com/FusionWorks/react-admin-google-maps. To use it we will need to update our Address Entity. We will store the pointer on the map like a JSON object in the JSON column in our DB.

import {
Entity,
Column,
PrimaryGeneratedColumn,
ManyToOne,
BaseEntity,
} from 'typeorm';
import { CompanyEntity } from '../company/company.entity';
@Entity({ name: 'address' })
export class AddressEntity extends BaseEntity {

...

@Column({
type: 'json',
nullable: true,
comment: '{ lng: string, lat: string }',
})
geo: { lng: string, lat: string };

...}

Now we need to update the views of addresses forms:

import { GMapInput, GMapField } from '@fusionworks/ra-google-maps-input';
import { parse } from 'query-string';
...export const AddressCreate = props => {
const { location } = props;
const { companyId } = parse(location.search);
const redirect = companyId ? `/company/${companyId}/show/addresses`: false;return (
<Create {...props}>
<SimpleForm redirect={redirect}> ... <GMapInput source="geo" searchable googleKey="YOUR KEY HERE" validate={validateRequired} />
</SimpleForm>
</Create>
);
};const AddressTitle = ({ record }) => (<span>{`${record.name}`}</span>);export const AddressEdit = props => (
<Edit {...props} title={<AddressTitle />}>
<SimpleForm redirect={(basePath, id, data) =>
`/company/${data.companyId}/show/addresses`}>

... <GMapInput source="geo" searchable googleKey="YOUR KEY HERE" validate={validateRequired} />
</SimpleForm>
</Edit>
);export const AddressShow = props => (
<Show {...props}>
<TabbedShowLayout>
<Tab label="resources.summary.name"> ... <GMapField source="geo" googleKey="YOUR KEY HERE" />
</Tab>
</TabbedShowLayout>
</Show>
);...

Once we’re done with this, we are ready to rock!

Run our backend from “api” folder:

yarn run start

And run our frontend part with react-admin from “admin-ui” folder:

yarn start

Conclusion

In this article, we touched on the relationship between entities, but there are many more topics that have not been addressed but will be described in the future. I will cover them in the next articles and we’ll see if this stack survives nicely. So stay tuned to the FusionWorks:

Useful links

Source code for the article: https://github.com/FusionWorks/nestjs-crud-react-admin-boilerplate

If you want to dive deeper yourself, here is the set of links to documentation that might be useful for you: