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 (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.