Skip to content

Commit d94877d

Browse files
authored
phase 3- frontend (#466)
1 parent 1b1a603 commit d94877d

File tree

14 files changed

+379
-99
lines changed

14 files changed

+379
-99
lines changed

ROADMAP.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -79,10 +79,10 @@ The project implements a **Hexagonal Architecture** backend (Java 24, Javalin, A
7979
- [x] **Validation**: Standardize `ValidationException` usage.
8080
- [x] **Observability**: Enhance logging and metrics.
8181
- **Frontend**:
82-
- [ ] **UX**: Add Toast notifications (Snackbars) for success/error actions.
83-
- [ ] **UX**: Implement Loading Skeletons for data fetching.
84-
- [ ] **Error Handling**: Add React Error Boundaries.
85-
- [ ] **Testing**: Add Unit tests for Components and Integration tests for flows.
82+
- [x] **UX**: Add Toast notifications (Snackbars) for success/error actions.
83+
- [x] **UX**: Implement Loading Skeletons for data fetching.
84+
- [x] **Error Handling**: Add React Error Boundaries.
85+
- [x] **Testing**: Add Unit tests for Components and Integration tests for flows.
8686

8787
### Phase 4: Architectural Evolution (Long Term)
8888

webapp/src/App.js

Lines changed: 55 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { ThemeProvider, createTheme } from "@mui/material/styles";
44
import CssBaseline from "@mui/material/CssBaseline";
55

66
import { AuthProvider } from "./contexts/AuthContext";
7+
import { SnackbarProvider } from "./contexts/SnackbarContext";
78
import Header from "./components/Header";
89
import Home from "./components/Home";
910
import AddLink from "./components/AddLink";
@@ -14,6 +15,8 @@ import NotFound from "./components/NotFound";
1415
import CollectionList from "./components/Collections/CollectionList";
1516
import CollectionDetail from "./components/Collections/CollectionDetail";
1617

18+
import ErrorBoundary from "./components/ErrorBoundary";
19+
1720
const theme = createTheme({
1821
palette: {
1922
primary: {
@@ -27,56 +30,60 @@ const theme = createTheme({
2730

2831
function App() {
2932
return (
30-
<AuthProvider>
31-
<ThemeProvider theme={theme}>
32-
<CssBaseline />
33-
<Header />
34-
<main>
35-
<Routes>
36-
{/* Public routes */}
37-
<Route path="/login" element={<Login />} />
38-
<Route path="/register" element={<Register />} />
33+
<ErrorBoundary>
34+
<AuthProvider>
35+
<ThemeProvider theme={theme}>
36+
<SnackbarProvider>
37+
<CssBaseline />
38+
<Header />
39+
<main>
40+
<Routes>
41+
{/* Public routes */}
42+
<Route path="/login" element={<Login />} />
43+
<Route path="/register" element={<Register />} />
3944

40-
{/* Protected routes */}
41-
<Route
42-
path="/"
43-
element={
44-
<ProtectedRoute>
45-
<Home />
46-
</ProtectedRoute>
47-
}
48-
/>
49-
<Route
50-
path="/add"
51-
element={
52-
<ProtectedRoute>
53-
<AddLink />
54-
</ProtectedRoute>
55-
}
56-
/>
57-
<Route
58-
path="/collections"
59-
element={
60-
<ProtectedRoute>
61-
<CollectionList />
62-
</ProtectedRoute>
63-
}
64-
/>
65-
<Route
66-
path="/collections/:id"
67-
element={
68-
<ProtectedRoute>
69-
<CollectionDetail />
70-
</ProtectedRoute>
71-
}
72-
/>
45+
{/* Protected routes */}
46+
<Route
47+
path="/"
48+
element={
49+
<ProtectedRoute>
50+
<Home />
51+
</ProtectedRoute>
52+
}
53+
/>
54+
<Route
55+
path="/add"
56+
element={
57+
<ProtectedRoute>
58+
<AddLink />
59+
</ProtectedRoute>
60+
}
61+
/>
62+
<Route
63+
path="/collections"
64+
element={
65+
<ProtectedRoute>
66+
<CollectionList />
67+
</ProtectedRoute>
68+
}
69+
/>
70+
<Route
71+
path="/collections/:id"
72+
element={
73+
<ProtectedRoute>
74+
<CollectionDetail />
75+
</ProtectedRoute>
76+
}
77+
/>
7378

74-
{/* Redirect root to login if not authenticated, otherwise to home */}
75-
<Route path="*" element={<NotFound />} />
76-
</Routes>
77-
</main>
78-
</ThemeProvider>
79-
</AuthProvider>
79+
{/* Redirect root to login if not authenticated, otherwise to home */}
80+
<Route path="*" element={<NotFound />} />
81+
</Routes>
82+
</main>
83+
</SnackbarProvider>
84+
</ThemeProvider>
85+
</AuthProvider>
86+
</ErrorBoundary>
8087
);
8188
}
8289

webapp/src/components/AddLink.js

Lines changed: 5 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,19 @@
11
import React, { useState } from "react";
2-
import { Container, Typography, Paper, Box, TextField, Button, Snackbar, Alert, CircularProgress } from "@mui/material";
2+
import { Container, Typography, Paper, Box, TextField, Button, CircularProgress } from "@mui/material";
33
import { useNavigate } from "react-router-dom";
44
import api from "../services/api";
5+
import { useSnackbar } from "../contexts/SnackbarContext";
56

67
const AddLink = () => {
78
const navigate = useNavigate();
9+
const { showSnackbar } = useSnackbar();
810
const [formData, setFormData] = useState({
911
url: "",
1012
title: "",
1113
description: ""
1214
});
1315
const [errors, setErrors] = useState({});
1416
const [loading, setLoading] = useState(false);
15-
const [snackbar, setSnackbar] = useState({
16-
open: false,
17-
message: "",
18-
severity: "success"
19-
});
2017

2118
const handleChange = (e) => {
2219
const { name, value } = e.target;
@@ -63,11 +60,7 @@ const AddLink = () => {
6360
setLoading(true);
6461
try {
6562
await api.createLink(formData);
66-
setSnackbar({
67-
open: true,
68-
message: "Link added successfully!",
69-
severity: "success"
70-
});
63+
showSnackbar("Link added successfully!", "success");
7164

7265
// Reset form
7366
setFormData({
@@ -98,23 +91,12 @@ const AddLink = () => {
9891
errorMessage = "Unable to connect to server. Please check your connection.";
9992
}
10093

101-
setSnackbar({
102-
open: true,
103-
message: errorMessage,
104-
severity: "error"
105-
});
94+
showSnackbar(errorMessage, "error");
10695
} finally {
10796
setLoading(false);
10897
}
10998
};
11099

111-
const handleCloseSnackbar = () => {
112-
setSnackbar({
113-
...snackbar,
114-
open: false
115-
});
116-
};
117-
118100
return (
119101
<Container maxWidth="md">
120102
<Box my={4}>
@@ -180,12 +162,6 @@ const AddLink = () => {
180162
</form>
181163
</Paper>
182164
</Box>
183-
184-
<Snackbar open={snackbar.open} autoHideDuration={6000} onClose={handleCloseSnackbar} anchorOrigin={{ vertical: "bottom", horizontal: "center" }}>
185-
<Alert onClose={handleCloseSnackbar} severity={snackbar.severity} variant="filled">
186-
{snackbar.message}
187-
</Alert>
188-
</Snackbar>
189165
</Container>
190166
);
191167
};

webapp/src/components/Collections/CollectionList.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,11 @@ import {
2222
import { Add, Delete, Folder, ArrowForward } from "@mui/icons-material";
2323
import { useNavigate } from "react-router-dom";
2424
import api from "../../services/api";
25+
import { useSnackbar } from "../../contexts/SnackbarContext";
2526

2627
const CollectionList = () => {
2728
const navigate = useNavigate();
29+
const { showSnackbar } = useSnackbar();
2830
const [collections, setCollections] = useState([]);
2931
const [loading, setLoading] = useState(true);
3032
const [error, setError] = useState(null);
@@ -59,9 +61,10 @@ const CollectionList = () => {
5961
setOpenCreateDialog(false);
6062
setNewCollection({ name: "", description: "" });
6163
fetchCollections();
64+
showSnackbar("Collection created successfully", "success");
6265
} catch (err) {
6366
console.error("Error creating collection:", err);
64-
// Ideally show a snackbar error here
67+
showSnackbar("Failed to create collection", "error");
6568
} finally {
6669
setCreateLoading(false);
6770
}
@@ -73,8 +76,10 @@ const CollectionList = () => {
7376
try {
7477
await api.deleteCollection(id);
7578
fetchCollections();
79+
showSnackbar("Collection deleted successfully", "success");
7680
} catch (err) {
7781
console.error("Error deleting collection:", err);
82+
showSnackbar("Failed to delete collection", "error");
7883
}
7984
}
8085
};
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import React from "react";
2+
import { render, screen, waitFor } from "@testing-library/react";
3+
import userEvent from "@testing-library/user-event";
4+
import { BrowserRouter } from "react-router-dom";
5+
import { ThemeProvider, createTheme } from "@mui/material/styles";
6+
import CollectionList from "../CollectionList";
7+
import { SnackbarProvider } from "../../../contexts/SnackbarContext";
8+
import api from "../../../services/api";
9+
10+
// Mock the API
11+
jest.mock("../../../services/api");
12+
13+
// Mock useNavigate
14+
const mockNavigate = jest.fn();
15+
jest.mock("react-router-dom", () => ({
16+
...jest.requireActual("react-router-dom"),
17+
useNavigate: () => mockNavigate
18+
}));
19+
20+
const theme = createTheme();
21+
22+
const renderWithProviders = (component) => {
23+
return render(
24+
<BrowserRouter>
25+
<ThemeProvider theme={theme}>
26+
<SnackbarProvider>{component}</SnackbarProvider>
27+
</ThemeProvider>
28+
</BrowserRouter>
29+
);
30+
};
31+
32+
describe("CollectionList Component", () => {
33+
beforeEach(() => {
34+
jest.clearAllMocks();
35+
// Setup default mocks
36+
api.listCollections = jest.fn();
37+
api.createCollection = jest.fn();
38+
api.deleteCollection = jest.fn();
39+
});
40+
41+
test("renders loading state initially", () => {
42+
api.listCollections.mockImplementation(() => new Promise(() => {}));
43+
renderWithProviders(<CollectionList />);
44+
expect(screen.getByRole("progressbar")).toBeInTheDocument();
45+
});
46+
47+
test("renders collections when data is loaded", async () => {
48+
const mockCollections = [{ id: "1", name: "Test Collection", description: "Test Description" }];
49+
api.listCollections.mockResolvedValue({ data: mockCollections });
50+
51+
renderWithProviders(<CollectionList />);
52+
53+
await waitFor(() => {
54+
expect(screen.getByText("Test Collection")).toBeInTheDocument();
55+
expect(screen.getByText("Test Description")).toBeInTheDocument();
56+
});
57+
});
58+
59+
test("renders empty state when no collections", async () => {
60+
api.listCollections.mockResolvedValue({ data: [] });
61+
62+
renderWithProviders(<CollectionList />);
63+
64+
await waitFor(() => {
65+
expect(screen.getByText("No collections found")).toBeInTheDocument();
66+
});
67+
});
68+
69+
test("opens create dialog", async () => {
70+
const user = userEvent.setup();
71+
api.listCollections.mockResolvedValue({ data: [] });
72+
73+
renderWithProviders(<CollectionList />);
74+
75+
await waitFor(() => {
76+
expect(screen.getByText("My Collections")).toBeInTheDocument();
77+
});
78+
79+
const createButton = screen.getByRole("button", { name: "New Collection" });
80+
await user.click(createButton);
81+
82+
expect(screen.getByRole("dialog")).toBeInTheDocument();
83+
expect(screen.getByText("Create New Collection")).toBeInTheDocument();
84+
});
85+
});

0 commit comments

Comments
 (0)