Frontend | Part 1 — User Interface to REST Endpoints

Part -1: Code + Demo

It's time to go full-stack! Let's add a dedicated frontend web app to allow users to interact with the REST endpoints from our custom Node.js API. The UI code from the templating engine in our deployed backend will be used for admin access to the database. Meanwhile, we'll employ React to enable regular, non-admin users to interact with the database via HTTP requests to our REST API.

Part 0: Setup

0.1: React with Vite

npm create vite

0.2: Libs

npm install react-router-dom@6.4  @mui/material @emotion/react @emotion/styled @mui/icons-material

Part 1: User Interface

1.1: App.jsx:

import HomePage from './_Page-Home';
import AboutPage from './_Page-About';

export default function App() {
  return (
    <SnackbarProvider maxSnack={3}>
      <BrowserRouter>
        <Routes>
            <Route path="/"      element={<HomePage />} />
            <Route path="/about" element={<AboutPage />} />
        </Routes>
      </BrowserRouter>
    </SnackbarProvider>
  );
}

1.2: _Page-Home.jsx:

import UsersTable from './table-users';
import Navbar from './navbar';
import CreateUserForm from './form-create-user';

import { http } from './util/http';
import { apiUrl } from './util/url';
import { sortDataById } from './util/sort';

import { useNotification } from './hooks/use-notification';

export default function HomePage () {

  const [users, setUsers] = useState([]);

  const [notify] = useNotification();

  const getUsers   = async () => { ... };
  const deleteUser = async (id) => { ... };
  const editUser   = async ({ id, updated_user }) => { ... };
  const createUser = async (user) => { ... };

  useEffect(() => getUsers(), []);

  return (
    <>
      <Navbar />

      <Container>
        <CreateUserForm { ...{ createUser } } />
        <UsersTable { ...{ users, editUser, deleteUser } } />
      </Container>
    </>
  );
};

1.3: form-create-user.jsx

const FC = ({ children }) => (
  <FormControl sx={{ m: 1, width: '25ch' }} variant="outlined">
    {children}
  </FormControl>
);

// ==============================================

const Password = ({ onChange, value }) => {
  const [showPassword, setShowPassword] = React.useState(false);

  const handleClickShowPassword = () => setShowPassword((show) => !show);

  const handleMouseDownPassword = (event) => {
    event.preventDefault();
  };

  return (
    <>
      <InputLabel htmlFor="outlined-adornment-password">Password</InputLabel>
      <OutlinedInput
        id="outlined-adornment-password"
        type={showPassword ? 'text' : 'password'}
        endAdornment={
          <InputAdornment position="end">
            <IconButton
              aria-label="toggle password visibility"
              onClick={handleClickShowPassword}
              onMouseDown={handleMouseDownPassword}
              edge="end"
            >
              {showPassword ? <VisibilityOff /> : <Visibility />}
            </IconButton>
          </InputAdornment>
        }
        label="Password"
        onChange={onChange}
        value={value}
      />
    </>
  );
}

// ==============================================

export default function ValidationTextFields({ createUser }) {

  const [email, setEmail] = React.useState('');
  const [password, setPassword] = React.useState('');
  const [is_admin, setIsAdmin] = React.useState(false);

  React.useEffect(() => console.log('email: ', email), [email]);
  React.useEffect(() => console.log('password: ', password), [password]);
  React.useEffect(() => console.log('is_admin: ', is_admin), [is_admin]);
  
  // ============================================

  return (
    <Box
      component="form"
      autoComplete="off"
    >
      
      <div style={{ 
        width: 'fit-content', 
        margin: '0 auto'}}
      >

        {/* = = = = = = = = = = = = = = = = = = = = = = */}

        <div>
          <FC>
            <TextField
              id="email-text-field"
              label="Email"
              onChange={e => setEmail(e.target.value)}
              value={email}
            />
          </FC>

          <FC>
            <Password 
              onChange={e => setPassword(e.target.value)}
              value={password}
            />
          </FC>
        </div>

        {/* = = = = = = = = = = = = = = = = = = = = = = */}

        <div>
          <FC>
            <FormControlLabel control={
              <Checkbox checked={is_admin} onChange={e => setIsAdmin(e.target.checked)} />
            } label="Admin?" />
          </FC>

          <FC>
            <Button 
              variant="contained" 
              onClick={() => createUser({ email, password, is_admin })}
              disabled={!(email && password)}
            >Create New User</Button>
          </FC>
        </div>
      

        {/* = = = = = = = = = = = = = = = = = = = = = = */}

      </div>

    </Box>
  );
}

1.4: table-users.jsx

import EditUserModal from './modal-edit-user';

export default function BasicTable({ users, editUser, deleteUser }) {
  return (
    <TableContainer component={Paper}>
      <Table sx={{ minWidth: 650 }} aria-label="simple table">
        <TableHead>
          <TableRow>
            <TableCell align="right">Email</TableCell>
            <TableCell align="right">Is Admin</TableCell>
            <TableCell align="right">Password&nbsp;(hashed)</TableCell>
            <TableCell align="right"></TableCell>
          </TableRow>
        </TableHead>
        <TableBody>
          {users.map((user) => (
            <TableRow
              key={user.email}
              sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
            >
              <TableCell align="right">{user.email}</TableCell>
              <TableCell align="right">{String(user.is_admin)}</TableCell>
              <TableCell align="right">{user.password}</TableCell>
              <TableCell align="right">
                <EditUserModal { ...{ user, editUser } }/>
                <Button variant="outlined" color="error" onClick={() => deleteUser(user.id)}>Delete</Button>
              </TableCell>
            </TableRow>
          ))}
        </TableBody>
      </Table>
    </TableContainer>
  );
}

1.5: modal-edit.jsx

export default function FormDialog({ user, editUser }) {
  const [open, setOpen] = React.useState(false);

  const [email, setEmail] = React.useState(user.email);
  React.useEffect(() => {
    console.log('email: ', email);
  }, [email]);

  const handleOpen = () => setOpen(true);
  const handleClose = () => setOpen(false);
  const handleSubmit = () => {
    editUser({ id: user.id, updated_user: { 
      email,
      password: user.password, // currently just set password to the same value (does not hash in backend update function yet)
      is_admin: user.is_admin, // currently just set is_admin to the same value
    } });
    setOpen(false);
  };

  return (
    <>
      <Button variant="outlined" color="success" sx={{ mr: 1 }} onClick={handleOpen}>Edit</Button>
      <Dialog open={open} onClose={handleClose}>
        <DialogTitle>Edit User {user.id}</DialogTitle>
        <DialogContent>
          <DialogContentText>
            Enter the new email address for user {user.id}:
          </DialogContentText>
          <TextField
            autoFocus
            margin="dense"
            id="name"
            label="Email Address"
            type="email"
            fullWidth
            variant="standard"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
          />
        </DialogContent>
        <DialogActions>
          <Button variant="outlined" color="info" onClick={handleClose}>Cancel</Button>
          <Button variant="outlined" color="success" onClick={handleSubmit}>Submit</Button>
        </DialogActions>
      </Dialog>
    </>
  );
}

Part 2: CRUD Operations

2.1: Create User

  const createUser = async (user) => {
    notify({message: 'creating new user...', variant: 'info'})();
    const URL = apiUrl('users');
    const data = await http({ url: URL, method: 'POST', body: { 
      email: user.email,
      password: user.password,
      is_admin: user.is_admin,
    } });
    notify({message: 'successfully created new user! 🙂', variant: 'success'})();
    console.log('data: ', data);
    getUsers();
  };

2.2: Read Users

  const getUsers = async () => {
    const URL = apiUrl('users');
    const data = await http({ url: URL });
    const sorted_data = sortDataById(data);
    // console.log('data: ', data);
    setUsers(sorted_data);
  };

2.3: Update User

  const editUser = async ({ id, updated_user }) => {
    notify({message: `updating user ${id}...`, variant: 'info'})();
    const endpoint = `users/${id}`;
    const URL = apiUrl(endpoint);
    const data = await http({ url: URL, method: 'PUT', body: { 
      id: +id,
      email: updated_user.email,
      password: updated_user.password,
      is_admin: updated_user.is_admin,
    } });
    notify({message: `successfully updated user ${id}! 🙂`, variant: 'success'})();
    console.log('data: ', data);
    getUsers();
  };

2.4: Delete User

  const deleteUser = async (id) => {
    notify({message: `deleting user ${id}...`, variant: 'warning', duration: 2000})();
    const endpoint = `users/${id}`;
    const URL = apiUrl(endpoint);
    const data = await http({ url: URL, method: 'DELETE' });
    notify({message: `successfully deleted user ${id}! 🙂`, variant: 'success'})();
    console.log('data: ', data);
    getUsers();
  };

Part 3: Utility functions and custom hooks

3.1: /util/http.js

const http = async ({ url, method='GET', body={} }) => {

  let debug_str = `%cmaking REQUEST to ${url} \n- METHOD:  ${method} \n- BODY: ${JSON.stringify(body, null, 2)}`;
  console.log(debug_str, 'color: orange');

  let resp;

  if (method === 'GET') {
    resp = await fetch(url);
  }
  else {
    resp = await fetch(url, {
      method,
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify( body ),
    });
  }

  // TODO: PROPER ERROR HANDLING!!!
  // if (!resp.ok) throw new Error(resp);

  const data = await resp.json();

  debug_str = `%cresponse -- DATA: ${JSON.stringify(data, null, 2)} \n CODE: ${resp.status}`;
  console.log(debug_str, 'color: #bada55');

  return data;
};

export { http };

/util/sort.js

const sortDataById = (data) => {
  return data.sort((a, b) => a.id - b.id);
};

export { sortDataById };

3.2: /util/url.js

const apiUrl = (str) => {
  const API_URL = import.meta.env.VITE_API_URL;
  const endpoint = `${API_URL}/api/${str}`;
  return endpoint;
};

export { apiUrl };

3.3: /util/use-notification.js

import { useSnackbar } from 'notistack';

const useNotification = () => {

  const { enqueueSnackbar } = useSnackbar();

  const notify = ({ message, variant, duration }) => () => {
    // variant: 'default' | 'error' | 'success' | 'warning' | 'info'
    enqueueSnackbar(message, { variant, autoHideDuration: duration });
  };

  return [ notify ];
};

export { useNotification };

Part 4: Disclaimer: The app is not robust (yet)!

Note that error handling is not yet implemented, making the app very brittle and far from robust in terms of fault tolerance and error recovery.

For example, if the user enters an email address that is already in the database for a new user, the app breaks! This is because we are using the email as the key property to map over unique virtual DOM elements in React.

Form validation is also not yet implemented. We are, of course, sanitizing the inputs before executing SQL queries to guard against SQL-injection attacks from hackers. However, we have not accounted for even the most basic cases of form validation, such as a user entering an invalid email address, etc.

We'll address these issues with a complete full-stack error handling flow, utilizing Test-Driven Development via end-to-end (e2e) and unit tests, in a separate "Testing" post.