DEV Community

Amalia Hajarani
Amalia Hajarani

Posted on

Customer: Use Case (4: Implementing Front-end Service)

Prerequisites

Installed Node version of v20.10.0

Brief Explanation

In this user interface I will fetch data from two services that I've created before which are ExpressJS service and NestJs service. For forms validation needs, I use Formik and Yup to make it easier. Lastly, since I just create a single page application I thought I would be overkill to use Redux, hence I use Lifting State Up approach from child to its parent.

Initialize React-Vite application

I'm assuming that you have already created project directories just like what I did in the first post.

  1. Open new command prompt from the front-end-service directory.
  2. Create new React using Vite by running command below. I choose React as the framework and JavaScript as the variant.

    npm create vite@latest .
    
  3. Run command:

    npm install
    
  4. Install these dependencies:

    Material UI Dependencies

    npm npm install @mui/material @emotion/react @emotion/styled
    

    Material UI Icon Dependencies

    npm install @mui/icons-material
    

    Axios dependency

    npm install axios
    

    Formik Yup dependency

    npm install formik yup
    

    My package.json dependencies looks like:

    "dependencies": {
        "@emotion/react": "^11.11.1",
        "@emotion/styled": "^11.11.0",
        "@mui/icons-material": "^5.14.18",
        "@mui/material": "^5.14.18",
        "axios": "^1.6.2",
        "formik": "^2.4.5",
        "react": "^18.2.0",
        "react-dom": "^18.2.0",
        "yup": "^1.3.2"
    },
    
  5. Create project directory skeleton that looks like this:

    front-end-service/
    ├── src/
    │   ├── assets/
    │   ├── components/
    │   ├── constants/
    │   ├── services/
    │   ├── utils/
    │   ├── views/
    │   ├── App.jsx
    │   ├── index.css
    │   ├── main.jsx
    │   └── theme.js
    ├── .env
    └── package.json
    
  6. My .env file looks like below. Makesure that you have correct endpoints and use VITE as the prefix.

    VITE_BASE_URL_EXPRESS=http://localhost:3001
    VITE_BASE_URL_NEST=http://localhost:3002
    

Configuring global theme

Since we are using Material UI, we have the independency to customize some components. I did few customization for typography, palette, and some components inside theme.js as you can see below:

import { createTheme, responsiveFontSizes } from "@mui/material";

const firstLetterUppercase = {
  '::first-letter': { 
    textTransform: 'uppercase',
  }
}

let theme = createTheme({
  palette: {
    primary: {
      main:'#FF4669',
      contrastText: '#fff'
    }, 
    secondary: {
      main: '#F5F5F5',
      contrastText: '#BDBDBD'
    }
  },
  typography: {
    fontFamily: [
      'Poppins', 
      'sans-serif'
    ].join(',')
  },
  components: {
    MuiButton: {
      styleOverrides: {
        root: {
          textTransform: 'none',
          borderRadius: 20
        }
      }
    },
    MuiInputLabel: {
      styleOverrides: {
        root: firstLetterUppercase
      }
    },
    MuiFormHelperText: {
      styleOverrides: {
        root: firstLetterUppercase
      }
    }
  }
});

theme = responsiveFontSizes(theme);

export default theme;
Enter fullscreen mode Exit fullscreen mode

Since I use Google Font, I need to define it inside index.css. My final index.css is look like this:

@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600&display=swap');

:root {
  font-family: 'Poppins', sans-serif;
}

body {
  background-color: #F5F5F5;
}

/* width */
::-webkit-scrollbar {
  width: 10px;
}

/* Handle */
::-webkit-scrollbar-thumb {
  background: #FF4669;
  border-radius: 10px;
}
Enter fullscreen mode Exit fullscreen mode

Now, to use our costumized theme, we have to define ThemeProvider in App.jsx. My final App.jsx looks like this:

import { ThemeProvider } from '@emotion/react'
import theme from './theme'
import Dashboard from './views/Dashboard'

function App() {

  return (
    <ThemeProvider theme={theme}>
      <Dashboard />
    </ThemeProvider>
  )
}

export default App
Enter fullscreen mode Exit fullscreen mode

Creating constants

As I've said, I'll be fetching data from two services, to make it more consistent, I will create a file called constants.js inside constants directory with content like this:

export const Constant = {
  EXPRESS_ID: 0,
  NEST_ID: 1
};
Enter fullscreen mode Exit fullscreen mode

Creating services and its helper function

Before we are creating some services to fetch data from APIs, I'll create a helper function to define the base url based on which service the data will be fetched. On utils directory, create a file called helperFunction.js and this is how it looks like:

import { Constant } from "../constants/constants"

export const defineBaseUrl = (serviceId) => {
  const BASE_URL = serviceId === Constant.EXPRESS_ID ? import.meta.env.VITE_BASE_URL_EXPRESS : import.meta.env.VITE_BASE_URL_NEST;

  return BASE_URL;
}
Enter fullscreen mode Exit fullscreen mode

Now, we are going to create a file called index.js inside services directory. As you might notice, we have same endpoints for both ExpressJS and NestJS service just different host. So here's how I creating my service so I could fetch the data from the wanted service:

import axios from "axios";
import { defineBaseUrl } from "../utils/helperFunction";

let BASE_URL = "";
let BASE_PREFIX = "/api/customers"

export const createCustomer = async (serviceId, requestBody) => {
  BASE_URL = defineBaseUrl(serviceId);

  try {
    const res = await axios.post(BASE_URL + BASE_PREFIX, requestBody);
    return res;
  } catch (error) {
    console.log(error);
  }
};

export const getCustomers = async (serviceId) => {
  BASE_URL = defineBaseUrl(serviceId);

  try {
    const res = await axios.get(BASE_URL + BASE_PREFIX);
    return res;
  } catch (error) {
    console.log(error);
  }
};

export const getCustomerById = async (serviceId, customerId) => {
  BASE_URL = defineBaseUrl(serviceId);

  try {
    const res = await axios.get(BASE_URL + BASE_PREFIX + `/${customerId}`);
    return res;
  } catch (error) {
    console.log(error);
  }
}

export const updateCustomer = async (serviceId, customerId, bodyRequest) => {
  BASE_URL = defineBaseUrl(serviceId);

  try {
    const res = await axios.put(BASE_URL + BASE_PREFIX + `/${customerId}`, bodyRequest);
    return res;
  } catch (error) {
    console.log(error);
  }
};

export const deleteCustomer = async (serviceId, customerId) => {
  BASE_URL = defineBaseUrl(serviceId);

  try {
    const res = await axios.delete(BASE_URL + BASE_PREFIX + `/${customerId}`);
    return res;
  } catch (error) {
    console.log(error);
  }
};
Enter fullscreen mode Exit fullscreen mode

Creating components

To make it more structured, I'm going to create some components first.

Table of costumers component

In this component, I'm using MUI's table components. Incomponents directory, create a new directory called CustomerDataList, then create a new file called index.jsx. It's a little tricky to do some loops because I want the user interface to use English as the language, but the response from backend is in Bahasa hence I do some tricky workaround. But the final look is like this:

import { CloseOutlined, DeleteForever, Edit, SearchRounded } from '@mui/icons-material';
import { Grid, IconButton, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TextField, Typography } from '@mui/material';
import { useFormik } from 'formik';
import React, { useEffect, useState } from 'react'
import * as Yup from 'yup';
import FormCustomerModal from '../FormCustomerModal';
import { deleteCustomer, getCustomerById, getCustomers, updateCustomer } from '../../services';

const header = [
  "ID",
  "Membership Number",
  "Name",
  "Address",
  "City",
  "Actions"
];

const structure = [
  "id",
  "no",
  "nama",
  "alamat",
  "kota",
  "aksi"
];

const initialValues = {
  id: '',
  no: '',
  nama: '',
  alamat: '',
  kota: ''
};

const validationSchema = Yup.object().shape({
  id: Yup.string(),
  no: Yup.string(),
  nama: Yup.string().required(),
  alamat: Yup.string().required(),
  kota: Yup.string().required(),
});

const CustomerDataList = ({ serviceID, onRefresh, onUpdate, onDelete }) => {
  const [dataTable, setDataTable] = useState([]);
  const [isUpdating, setIsUpdating] = useState(false);
  const [searchedId, setSearchedId] = useState('');
  const [isSearching, setIsSearching] = useState(false);
  const [isDataNotFound, setIsDataNotFound] = useState(false);

  const updateCustomerFormik = useFormik({
    initialValues: initialValues,
    validationSchema: validationSchema,
    enableReinitialize: true,
    onSubmit: () => {
      handleUpdateCustomer();
      setIsUpdating(false);
    }
  });

  const handleUpdateFormikValue = ( index ) => {
    updateCustomerFormik.setValues(dataTable[index]);
    setIsUpdating(true)
  }

  const handleCloseUpdateModal = () => {
    setIsUpdating(false);
  }

  const handleGetAllCustomers = () => {
    getCustomers(serviceID)
      .then((res) => {
        if (res.status === 200) {
          setDataTable([...res.data]);
        }
      }).catch((err) => {
        console.log(err);
      });
  }

  const handleUpdateCustomer = () => {
    const customerId = updateCustomerFormik.values.id;

    const requestBody = {
      nama: updateCustomerFormik.values.nama,
      alamat: updateCustomerFormik.values.alamat,
      kota: updateCustomerFormik.values.kota
    };

    updateCustomer(serviceID, customerId, requestBody)
      .then(() => {
        onUpdate();
      }).catch((err) => {
        console.log(err);
      });
  }

  const handleDeleteCustomer = ( index ) => {
    const customerId = dataTable[index].id;

    deleteCustomer(serviceID, customerId)
      .then(() => {
        onDelete()
      }).catch((err) => {
        console.log(err);
      });
  }

  const handleSearchCustomer = () => {
    if(searchedId !== '') {
      setIsSearching(true);
      getCustomerById(serviceID, searchedId)
        .then((res) => {
          if (res.status === 200 && Object.keys(res.data).length > 0) {
            setIsDataNotFound(false);
            setDataTable([res.data]);
          } else {
            setIsDataNotFound(true);
          }
        }).catch((err) => {
          console.log(err);
        });
    }
  }

  const handleCloseSearching= () => {
    setIsSearching(false);
    setSearchedId('');
    handleGetAllCustomers();
  }

  useEffect(() => {
    handleGetAllCustomers();
  }, [serviceID, onRefresh, isUpdating, onDelete]);

  useEffect(() => {
    setSearchedId('');
  }, [serviceID]);

  const ActionButtonGroups = ({ index }) => {
    return (
      <Grid container>
        <Grid item>
          <IconButton 
            color='warning'
            onClick={() => handleUpdateFormikValue(index)}
          >
            <Edit />
          </IconButton>
        </Grid>
        <Grid item>
          <IconButton 
            color='error'
            onClick={() => handleDeleteCustomer(index)}
          >
            <DeleteForever />
          </IconButton>
        </Grid>
      </Grid>
    );
  }

  return (
    <>
      <TextField
        fullWidth
        variant='filled'
        placeholder='Search by id'
        value={searchedId}
        onChange={(e) => setSearchedId(e.target.value)}
        sx={{
          backgroundColor: 'primary.main',
          borderTopLeftRadius: '8px',
          borderTopRightRadius: '8px',
        }}
        inputProps={{
          sx: {
            paddingY: '1rem',
            color: 'primary.contrastText'
          },
        }}
        InputProps={{
          endAdornment: (
            isSearching 
            ? (
              <IconButton
                sx={{ color: 'primary.contrastText' }}
                onClick={handleCloseSearching}
              >
                <CloseOutlined />
              </IconButton>
            ) : (
              <IconButton 
                sx={{ color: 'primary.contrastText' }}
                onClick={handleSearchCustomer}
              >
                <SearchRounded />
              </IconButton>
            )
          )
        }}
      />
      <TableContainer component={Paper} sx={{ maxHeight: 375, boxShadow: 'none'}}>
        <Table sx={{ width: '100%' }} stickyHeader>
          <TableHead>
            <TableRow>
              {
                header.map((item, index) => {
                  return (
                    <TableCell 
                      align="left" 
                      key={index} 
                      sx={{
                        color: '#6D6C6D',
                        borderBottom: '2px solid',
                        borderColor: 'primary.main'
                      }}
                    >
                      {item}
                    </TableCell>
                  )
                })
              }
            </TableRow>
          </TableHead>
          <TableBody>
            {
              isSearching && isDataNotFound 
              ? (
                <TableRow>
                  <TableCell colSpan={header.length} align={"center"}>
                    <Typography>No Customer Found</Typography>
                  </TableCell>
                </TableRow>
              ) : (
                dataTable.map((row, index) => {
                  return (
                    <TableRow key={index}>
                      {
                        structure.map((column) => {
                          return (
                            <TableCell>
                              {
                                column === 'aksi' 
                                ? <ActionButtonGroups index={index} />
                                : <Typography>{row[column.toLowerCase()]}</Typography>
                              }
                            </TableCell>
                          )
                        })
                      }
                    </TableRow>
                  )
                })
              )
            }
          </TableBody>
        </Table>
      </TableContainer>

      <FormCustomerModal 
        title={"Update Customer Data"}
        open={isUpdating}
        handleClose={handleCloseUpdateModal}
        formikProps={updateCustomerFormik}
        disabledField={[ "id", "no", "createdAt", "updatedAt" ]}
      />
    </>
  )
}

export default CustomerDataList;
Enter fullscreen mode Exit fullscreen mode

Form dialogue component

As you might notice in previous component, I have a component called FormCustomerModal. This component will be used as a dialogue containing form to add a customer and update an existing customer. First thing first, in components directory, create a new folder called FormCustomerModal, then inside that directory, create a new file called index.jsx. The final look of this file is just like below:

import { Button, Dialog, DialogContent, DialogTitle, Grid, TextField } from '@mui/material';
import React from 'react'

const FormCustomerModal = ({ title, open, handleClose, formikProps, disabledField=[] }) => {
  return (
    <React.Fragment>
      <Dialog 
        open={open} 
        onClose={handleClose}
        fullWidth
      >
        <DialogTitle sx={{ textAlign: 'center' }}>{ title }</DialogTitle>
        <DialogContent>
          <form onSubmit={formikProps.handleSubmit}>
            <Grid 
              container 
              spacing={2}
              flexDirection={'column'} 
              sx={{ padding: '0.5rem'}}
            >
              {
                Object.keys(formikProps.values).map((item, index) => {
                  return (
                    <Grid item key={index}>
                      <TextField 
                        disabled={ disabledField.includes(item) ? true : false }
                        id="outlined-basic"
                        variant="outlined"
                        name={item}
                        label={item}
                        value={formikProps.values[item]}
                        onChange={formikProps.handleChange}
                        onBlur={formikProps.handleBlur}
                        error={formikProps.touched[item] && formikProps.errors[item]}
                        helperText={formikProps.touched[item] && formikProps.errors[item] && `${item} cannot be empty`}
                        fullWidth
                      />
                    </Grid>
                  )
                })
              }
              <Grid item sx={{ alignSelf: 'center' }}>
                <Button variant='contained' type='submit'> Save Customer </Button>
              </Grid>
            </Grid>
          </form>
        </DialogContent>
      </Dialog>
    </React.Fragment>
  )
}

export default FormCustomerModal;
Enter fullscreen mode Exit fullscreen mode

Creating view of Dashboard

Now we already have all of the components and service, we can safely create the view. In views directory, create a folder called Dashboard then create a new file called indes.jsx. This is the content of our dashboard view:

import { Alert, Button, Container, Grid, Tab, Tabs, Typography } from '@mui/material'
import React, { useEffect, useState } from 'react'
import CustomerDataList from '../../components/CustomerDataList'
import { AddCircleOutlineRounded } from '@mui/icons-material'
import * as Yup from 'yup';
import { useFormik } from 'formik';
import FormCustomerModal from '../../components/FormCustomerModal';
import { createCustomer } from '../../services';
import { Constant } from '../../constants/constants';

const initialValues = {
  nama: '',
  alamat: '',
  kota: ''
};

const validationSchema = Yup.object().shape({
  nama: Yup.string().required(),
  alamat: Yup.string().required(),
  kota: Yup.string().required(),
})

const Dashboard = () => {
  const [tabValue, setTabValue] = useState(0);
  const [isAddingNewCustomer, setIsAddingNewCustomer] = useState(false);
  const [refreshTable, setRefreshTable] = useState(true);
  const [alert, setAlert] = useState({
    open: false,
    severity: '',
    message: ''
  });

  const handleTabChange = (e, newValue) => {
    setTabValue(newValue);
  }

  const newCustomerFormik = useFormik({
    initialValues: initialValues,
    validationSchema: validationSchema,
    onSubmit: (values) => {
      handleAddNewCustomer(values);
    }
  });

  const handleCloseAddingNewCustomer = () => {
    setIsAddingNewCustomer(false);
  }

  const handleAddNewCustomer = async (values) => {
    createCustomer(tabValue, values)
      .then(() => {
        setRefreshTable(true);
        setAlert({
          open: true,
          severity: 'success',
          message: `Successfully creating customer using Service ${tabValue === Constant.EXPRESS_ID ? 'ExpressJs': 'NestJs'}`
        })
      }).catch((err) => {
        setRefreshTable(false);
      }).finally(() => {
        setIsAddingNewCustomer(false);
      });
  }

  const handleUpdateCustomer = () => {
    setAlert({
      open: true,
      severity: 'success',
      message: `Successfully updating customer using Service ${tabValue === Constant.EXPRESS_ID ? 'ExpressJs': 'NestJs'}`
    })
  }

  const handleDeleteCustomer = () => {
    setAlert({
      open: true,
      severity: 'success',
      message: `Successfully deleting customer using Service ${tabValue === Constant.EXPRESS_ID ? 'ExpressJs': 'NestJs'}`
    })
  }

  useEffect(() => {
    if (alert.open) {
      const timeOut = setTimeout(() => {
        setAlert({
          open: false,
          severity: '',
          message: ''
        });
      }, 3000)

      return () => {
        clearTimeout(timeOut)
      }
    }
  }, [alert])

  return (
    <Container>
      <Alert 
        severity={alert.severity} 
        sx={{ visibility: alert.open ? 'visible' : 'hidden' }}
      > 
        {alert.message} 
      </Alert>

      <Grid 
        container
        spacing={4}
        flexDirection={"column"}
        alignItems={"center"} 
        justifyContent={"center"} 
        height={'95vh'}
      >
        <Grid item width={"100%"}>
          <Typography variant={"h5"} textAlign={"center"}>Customer List</Typography>
        </Grid>
        <Grid item width={"100%"}>
          <Grid container spacing={4} flexDirection={"column"} paddingTop={0}>
            <Grid item width={"100%"}>
              <Tabs value={tabValue} onChange={handleTabChange} centered>
                <Tab label="ExpressJS" sx={{ flexGrow: 1}} />
                <Tab label="NestJS" sx={{ flexGrow: 1}} />
              </Tabs>
            </Grid>
            <Grid item>
              <CustomerDataList 
                serviceID={tabValue} 
                onRefresh={() => setRefreshTable(refreshTable)}
                onUpdate={() => handleUpdateCustomer()}
                onDelete={() => handleDeleteCustomer()}
              />
            </Grid>
            <Grid item alignSelf={"flex-end"}>
              <Button 
                variant='contained' 
                startIcon={<AddCircleOutlineRounded />}
                onClick={() => setIsAddingNewCustomer(true)}
              >
                Add Customer
              </Button>
            </Grid>
          </Grid>
        </Grid>
      </Grid>

      <FormCustomerModal 
        title={'Add New Customer'}
        open={isAddingNewCustomer}
        handleClose={handleCloseAddingNewCustomer}
        formikProps={newCustomerFormik}
      />
    </Container>
  )
}

export default Dashboard;
Enter fullscreen mode Exit fullscreen mode

Running application

  1. Makesure you run the ExpressJS and NestJS service.
  2. Back to front-end-service root directory, open command prompt directed to the directory.
  3. Run npm run dev. You will see a localhost or similar address where you can see the interface.
  4. If it is running correctly you will see this kind of user interface along with the functionality that we already build!
    Image description

  5. To makesure that we did hit both services, we can see it at Network section while inspecting element like when I'm at ExpressJS tab, I have this:
    Image description
    When I move to the NestJS tab I have this:
    Image description
    If you notice, the first one is hitting http://localhost:3001 which is the ExpressJS service and the second one is hitting http://localhost:3002 which is the NestJS service.

Top comments (0)