From 1b420271c7e9979bd20a7f1ff5a7088182fd8959 Mon Sep 17 00:00:00 2001 From: Achim Rohn Date: Thu, 21 May 2026 13:43:17 +0200 Subject: [PATCH] Add user and project rate editing across backend and frontend --- .../app/project/ProjectController.java | 7 + .../com/example/app/project/ProjectDtos.java | 6 + .../example/app/project/ProjectService.java | 27 +++ .../com/example/app/user/UserController.java | 10 + .../java/com/example/app/user/UserDtos.java | 6 + .../com/example/app/user/UserService.java | 14 ++ .../example/app/RateUpdateEndpointsTest.java | 123 ++++++++++ .../app/time/TimeEntryRepositoryTest.java | 40 ++++ frontend/src/components/ProjectsView.tsx | 226 +++++++++++++++--- frontend/src/components/TopBar.tsx | 95 +++++++- frontend/src/lib/api.ts | 13 + frontend/src/state/UserContext.tsx | 35 ++- frontend/tests/TopBar.test.tsx | 61 +++++ 13 files changed, 623 insertions(+), 40 deletions(-) create mode 100644 backend/src/test/java/com/example/app/RateUpdateEndpointsTest.java create mode 100644 frontend/tests/TopBar.test.tsx diff --git a/backend/src/main/java/com/example/app/project/ProjectController.java b/backend/src/main/java/com/example/app/project/ProjectController.java index f3fa058..b3150bb 100644 --- a/backend/src/main/java/com/example/app/project/ProjectController.java +++ b/backend/src/main/java/com/example/app/project/ProjectController.java @@ -2,6 +2,7 @@ package com.example.app.project; import com.example.app.project.ProjectDtos.CreateProjectRequest; import com.example.app.project.ProjectDtos.ProjectResponse; +import com.example.app.project.ProjectDtos.UpdateProjectRequest; import jakarta.validation.Valid; import java.util.List; import org.springframework.http.HttpStatus; @@ -27,4 +28,10 @@ public class ProjectController { public ProjectResponse create(@Valid @RequestBody CreateProjectRequest req) { return projectService.create(req); } + + @PutMapping("/{id}") + public ProjectResponse update( + @PathVariable Long id, @Valid @RequestBody UpdateProjectRequest req) { + return projectService.update(id, req); + } } diff --git a/backend/src/main/java/com/example/app/project/ProjectDtos.java b/backend/src/main/java/com/example/app/project/ProjectDtos.java index b6cfb4b..91d4dae 100644 --- a/backend/src/main/java/com/example/app/project/ProjectDtos.java +++ b/backend/src/main/java/com/example/app/project/ProjectDtos.java @@ -12,6 +12,12 @@ public class ProjectDtos { @PositiveOrZero int costBudgetCents, @PositiveOrZero Integer hourlyRateCents) {} + public record UpdateProjectRequest( + @NotBlank String name, + @Min(1) int timeBudgetMinutes, + @PositiveOrZero int costBudgetCents, + @PositiveOrZero Integer hourlyRateCents) {} + public record ProjectResponse( Long id, String name, diff --git a/backend/src/main/java/com/example/app/project/ProjectService.java b/backend/src/main/java/com/example/app/project/ProjectService.java index 2dcbd4a..f4a9f12 100644 --- a/backend/src/main/java/com/example/app/project/ProjectService.java +++ b/backend/src/main/java/com/example/app/project/ProjectService.java @@ -2,7 +2,9 @@ package com.example.app.project; import com.example.app.project.ProjectDtos.CreateProjectRequest; import com.example.app.project.ProjectDtos.ProjectResponse; +import com.example.app.project.ProjectDtos.UpdateProjectRequest; import com.example.app.time.TimeEntryRepository; +import jakarta.persistence.EntityNotFoundException; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -53,4 +55,29 @@ public class ProjectService { Project saved = projectRepository.save(p); return ProjectResponse.from(saved, 0, 0); } + + @Transactional + public ProjectResponse update(Long id, UpdateProjectRequest req) { + Project project = + projectRepository + .findById(id) + .orElseThrow(() -> new EntityNotFoundException("Project not found: " + id)); + project.setName(req.name()); + project.setTimeBudgetMinutes(req.timeBudgetMinutes()); + project.setCostBudgetCents(req.costBudgetCents()); + project.setHourlyRateCents(req.hourlyRateCents()); + Project saved = projectRepository.save(project); + + long[] totals = new long[] {0, 0}; + for (Object[] row : timeEntryRepository.aggregateAllByProject()) { + long projectId = ((Number) row[0]).longValue(); + if (projectId == saved.getId()) { + totals[0] = ((Number) row[1]).longValue(); + totals[1] = ((Number) row[2]).longValue(); + break; + } + } + + return ProjectResponse.from(saved, totals[0], totals[1]); + } } diff --git a/backend/src/main/java/com/example/app/user/UserController.java b/backend/src/main/java/com/example/app/user/UserController.java index 9a94517..14dc77f 100644 --- a/backend/src/main/java/com/example/app/user/UserController.java +++ b/backend/src/main/java/com/example/app/user/UserController.java @@ -1,8 +1,13 @@ package com.example.app.user; +import com.example.app.user.UserDtos.UpdateUserRequest; import com.example.app.user.UserDtos.UserResponse; +import jakarta.validation.Valid; import java.util.List; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -20,4 +25,9 @@ public class UserController { public List list() { return userService.findAll(); } + + @PutMapping("/{id}") + public UserResponse update(@PathVariable Long id, @Valid @RequestBody UpdateUserRequest req) { + return userService.update(id, req); + } } diff --git a/backend/src/main/java/com/example/app/user/UserDtos.java b/backend/src/main/java/com/example/app/user/UserDtos.java index 0f1d2d7..ed201ae 100644 --- a/backend/src/main/java/com/example/app/user/UserDtos.java +++ b/backend/src/main/java/com/example/app/user/UserDtos.java @@ -1,7 +1,13 @@ package com.example.app.user; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.PositiveOrZero; + public class UserDtos { + public record UpdateUserRequest( + @NotBlank String name, @PositiveOrZero int defaultHourlyRateCents) {} + public record UserResponse(Long id, String name, int defaultHourlyRateCents) { public static UserResponse from(User user) { diff --git a/backend/src/main/java/com/example/app/user/UserService.java b/backend/src/main/java/com/example/app/user/UserService.java index 152db4f..23e8326 100644 --- a/backend/src/main/java/com/example/app/user/UserService.java +++ b/backend/src/main/java/com/example/app/user/UserService.java @@ -1,6 +1,8 @@ package com.example.app.user; +import com.example.app.user.UserDtos.UpdateUserRequest; import com.example.app.user.UserDtos.UserResponse; +import jakarta.persistence.EntityNotFoundException; import java.util.List; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -18,4 +20,16 @@ public class UserService { public List findAll() { return userRepository.findAll().stream().map(UserResponse::from).toList(); } + + @Transactional + public UserResponse update(Long id, UpdateUserRequest req) { + User user = + userRepository + .findById(id) + .orElseThrow(() -> new EntityNotFoundException("User not found: " + id)); + user.setName(req.name()); + user.setDefaultHourlyRateCents(req.defaultHourlyRateCents()); + User saved = userRepository.save(user); + return UserResponse.from(saved); + } } diff --git a/backend/src/test/java/com/example/app/RateUpdateEndpointsTest.java b/backend/src/test/java/com/example/app/RateUpdateEndpointsTest.java new file mode 100644 index 0000000..1e611c8 --- /dev/null +++ b/backend/src/test/java/com/example/app/RateUpdateEndpointsTest.java @@ -0,0 +1,123 @@ +package com.example.app; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.example.app.project.Project; +import com.example.app.project.ProjectRepository; +import com.example.app.user.User; +import com.example.app.user.UserRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +class RateUpdateEndpointsTest { + + @Autowired MockMvc mockMvc; + @Autowired ObjectMapper objectMapper; + @Autowired UserRepository userRepository; + @Autowired ProjectRepository projectRepository; + + @Test + void putUser_updatesNameAndDefaultRate() throws Exception { + User user = userRepository.findAll().get(0); + String originalName = user.getName(); + int originalRate = user.getDefaultHourlyRateCents(); + + try { + String body = + objectMapper.writeValueAsString( + Map.of("name", "Updated User", "defaultHourlyRateCents", 12345)); + + mockMvc + .perform( + put("/api/users/{id}", user.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("Updated User")) + .andExpect(jsonPath("$.defaultHourlyRateCents").value(12345)); + } finally { + user.setName(originalName); + user.setDefaultHourlyRateCents(originalRate); + userRepository.save(user); + } + } + + @Test + void putUser_rejectsNegativeRate() throws Exception { + User user = userRepository.findAll().get(0); + String body = + objectMapper.writeValueAsString(Map.of("name", "X", "defaultHourlyRateCents", -1)); + + mockMvc + .perform( + put("/api/users/{id}", user.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isBadRequest()); + } + + @Test + void putProject_updatesFieldsIncludingRate() throws Exception { + Project project = projectRepository.findAll().get(0); + String originalName = project.getName(); + int originalTime = project.getTimeBudgetMinutes(); + int originalCost = project.getCostBudgetCents(); + Integer originalRate = project.getHourlyRateCents(); + + try { + String body = + objectMapper.writeValueAsString( + Map.of( + "name", "Updated Project", + "timeBudgetMinutes", 600, + "costBudgetCents", 99999, + "hourlyRateCents", 15000)); + + mockMvc + .perform( + put("/api/projects/{id}", project.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("Updated Project")) + .andExpect(jsonPath("$.timeBudgetMinutes").value(600)) + .andExpect(jsonPath("$.costBudgetCents").value(99999)) + .andExpect(jsonPath("$.hourlyRateCents").value(15000)); + } finally { + project.setName(originalName); + project.setTimeBudgetMinutes(originalTime); + project.setCostBudgetCents(originalCost); + project.setHourlyRateCents(originalRate); + projectRepository.save(project); + } + } + + @Test + void putProject_rejectsBlankName() throws Exception { + Project project = projectRepository.findAll().get(0); + String body = + objectMapper.writeValueAsString( + Map.of( + "name", "", + "timeBudgetMinutes", 600, + "costBudgetCents", 99999, + "hourlyRateCents", 15000)); + + mockMvc + .perform( + put("/api/projects/{id}", project.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isBadRequest()); + } +} diff --git a/backend/src/test/java/com/example/app/time/TimeEntryRepositoryTest.java b/backend/src/test/java/com/example/app/time/TimeEntryRepositoryTest.java index 8b72971..2b77c3d 100644 --- a/backend/src/test/java/com/example/app/time/TimeEntryRepositoryTest.java +++ b/backend/src/test/java/com/example/app/time/TimeEntryRepositoryTest.java @@ -45,6 +45,46 @@ class TimeEntryRepositoryTest { timeEntryRepository.deleteAll(List.of(inRange, outOfRange)); } + @Test + void aggregateByDay_reflectsUpdatedRatesRetroactively() { + User user = new User(); + user.setName("Rate Test User"); + user.setDefaultHourlyRateCents(6000); + user = userRepository.save(user); + + Project project = new Project(); + project.setName("Rate Test Project"); + project.setTimeBudgetMinutes(600); + project.setCostBudgetCents(100_000); + project.setHourlyRateCents(null); + project = projectRepository.save(project); + + LocalDate day = LocalDate.of(2026, 5, 20); + TimeEntry entry = entry(user, project, day, 60); + entry = timeEntryRepository.save(entry); + + try { + List initial = timeEntryRepository.aggregateByDay(user.getId(), day, day); + assertThat(initial).hasSize(1); + assertThat(((Number) initial.get(0)[2]).longValue()).isEqualTo(6000); + + user.setDefaultHourlyRateCents(9000); + userRepository.save(user); + List updatedUserRate = timeEntryRepository.aggregateByDay(user.getId(), day, day); + assertThat(((Number) updatedUserRate.get(0)[2]).longValue()).isEqualTo(9000); + + project.setHourlyRateCents(12000); + projectRepository.save(project); + List updatedProjectRate = + timeEntryRepository.aggregateByDay(user.getId(), day, day); + assertThat(((Number) updatedProjectRate.get(0)[2]).longValue()).isEqualTo(12000); + } finally { + timeEntryRepository.delete(entry); + projectRepository.delete(project); + userRepository.delete(user); + } + } + private TimeEntry entry(User user, Project project, LocalDate date, int minutes) { TimeEntry e = new TimeEntry(); e.setUser(user); diff --git a/frontend/src/components/ProjectsView.tsx b/frontend/src/components/ProjectsView.tsx index 9e4d6dc..58fb4c1 100644 --- a/frontend/src/components/ProjectsView.tsx +++ b/frontend/src/components/ProjectsView.tsx @@ -15,27 +15,96 @@ function ProgressBar({ pct, exceeded }: { pct: number; exceeded: boolean }) { ); } -interface CreateFormState { +interface ProjectFormState { name: string; timeBudgetHours: string; costBudget: string; hourlyRate: string; } -const emptyForm = (): CreateFormState => ({ +const emptyForm = (): ProjectFormState => ({ name: "", timeBudgetHours: "", costBudget: "", hourlyRate: "", }); +function toForm(project: Project): ProjectFormState { + return { + name: project.name, + timeBudgetHours: (project.timeBudgetMinutes / 60).toString(), + costBudget: centsToEur(project.costBudgetCents), + hourlyRate: + project.hourlyRateCents === null + ? "" + : centsToEur(project.hourlyRateCents), + }; +} + +function parseForm(form: ProjectFormState): + | { + ok: true; + body: { + name: string; + timeBudgetMinutes: number; + costBudgetCents: number; + hourlyRateCents: number | null; + }; + } + | { ok: false; error: string } { + const hours = Number.parseFloat(form.timeBudgetHours); + const cost = Number.parseFloat(form.costBudget); + const rateStr = form.hourlyRate.trim(); + + if (!form.name.trim()) return { ok: false, error: "Name is required." }; + if (Number.isNaN(hours) || hours <= 0) { + return { + ok: false, + error: "Time budget must be a positive number of hours.", + }; + } + if (Number.isNaN(cost) || cost < 0) { + return { + ok: false, + error: "Cost budget must be a non-negative number in EUR.", + }; + } + + let hourlyRateCents: number | null = null; + if (rateStr) { + const rate = Math.round(Number.parseFloat(rateStr) * 100); + if (Number.isNaN(rate) || rate < 0) { + return { + ok: false, + error: "Hourly rate must be a non-negative number in EUR.", + }; + } + hourlyRateCents = rate; + } + + return { + ok: true, + body: { + name: form.name.trim(), + timeBudgetMinutes: Math.round(hours * 60), + costBudgetCents: Math.round(cost * 100), + hourlyRateCents, + }, + }; +} + export function ProjectsView() { const [projects, setProjects] = useState([]); const [showForm, setShowForm] = useState(false); - const [form, setForm] = useState(emptyForm()); + const [form, setForm] = useState(emptyForm()); const [formError, setFormError] = useState(null); const [saving, setSaving] = useState(false); + const [editingProjectId, setEditingProjectId] = useState(null); + const [editForm, setEditForm] = useState(emptyForm()); + const [editError, setEditError] = useState(null); + const [savingEdit, setSavingEdit] = useState(false); + const load = useCallback(() => { api.projects.list().then(setProjects); }, []); @@ -48,41 +117,15 @@ export function ProjectsView() { e.preventDefault(); setFormError(null); - const hours = Number.parseFloat(form.timeBudgetHours); - const cost = Number.parseFloat(form.costBudget); - const rateStr = form.hourlyRate.trim(); - - if (!form.name.trim()) { - setFormError("Name is required."); + const parsed = parseForm(form); + if (!parsed.ok) { + setFormError(parsed.error); return; } - if (Number.isNaN(hours) || hours <= 0) { - setFormError("Time budget must be a positive number of hours."); - return; - } - if (Number.isNaN(cost) || cost < 0) { - setFormError("Cost budget must be a non-negative number in EUR."); - return; - } - - let hourlyRateCents: number | null = null; - if (rateStr) { - const rate = Math.round(Number.parseFloat(rateStr) * 100); - if (Number.isNaN(rate) || rate < 0) { - setFormError("Hourly rate must be a non-negative number in EUR."); - return; - } - hourlyRateCents = rate; - } setSaving(true); try { - await api.projects.create({ - name: form.name.trim(), - timeBudgetMinutes: Math.round(hours * 60), - costBudgetCents: Math.round(cost * 100), - hourlyRateCents, - }); + await api.projects.create(parsed.body); setForm(emptyForm()); setShowForm(false); load(); @@ -95,6 +138,37 @@ export function ProjectsView() { } }; + const startEdit = (project: Project) => { + setEditingProjectId(project.id); + setEditForm(toForm(project)); + setEditError(null); + }; + + const handleSaveEdit = async () => { + if (editingProjectId === null) return; + setEditError(null); + + const parsed = parseForm(editForm); + if (!parsed.ok) { + setEditError(parsed.error); + return; + } + + setSavingEdit(true); + try { + await api.projects.update(editingProjectId, parsed.body); + setEditingProjectId(null); + setEditForm(emptyForm()); + load(); + } catch (err) { + setEditError( + err instanceof Error ? err.message : "Failed to update project.", + ); + } finally { + setSavingEdit(false); + } + }; + const breachedProjects = projects.filter( (p) => p.timeExceeded || p.costExceeded, ); @@ -112,7 +186,6 @@ export function ProjectsView() { - {/* Budget warnings */} {breachedProjects.map((p) => ( ))} - {/* Create form */} {showForm && (
)} - {/* Project list */} + {editingProjectId !== null && ( +
+

Edit Project

+ {editError &&

{editError}

} + +
+ + setEditForm({ ...editForm, name: e.target.value }) + } + placeholder="Name" + className="border rounded px-2 py-1.5 text-sm col-span-2" + /> + + setEditForm({ ...editForm, timeBudgetHours: e.target.value }) + } + placeholder="Time budget hours" + min="0.1" + step="0.5" + className="border rounded px-2 py-1.5 text-sm" + /> + + setEditForm({ ...editForm, costBudget: e.target.value }) + } + placeholder="Cost budget EUR" + min="0" + step="100" + className="border rounded px-2 py-1.5 text-sm" + /> + + setEditForm({ ...editForm, hourlyRate: e.target.value }) + } + placeholder="Hourly rate EUR (blank = per user)" + min="0" + step="5" + className="border rounded px-2 py-1.5 text-sm col-span-2" + /> +
+ +
+ + +
+
+ )} + {projects.length > 0 ? (
@@ -240,6 +384,7 @@ export function ProjectsView() { + @@ -292,6 +437,15 @@ export function ProjectsView() { + ); })} diff --git a/frontend/src/components/TopBar.tsx b/frontend/src/components/TopBar.tsx index 82f864c..15d1a48 100644 --- a/frontend/src/components/TopBar.tsx +++ b/frontend/src/components/TopBar.tsx @@ -1,8 +1,58 @@ +import { useEffect, useState } from "react"; import { NavLink } from "react-router-dom"; +import { centsToEur } from "../lib/format"; import { useUser } from "../state/UserContext"; export function TopBar() { - const { users, selectedUserId, setSelectedUserId } = useUser(); + const { + users, + selectedUserId, + setSelectedUserId, + selectedUser, + updateSelectedUser, + } = useUser(); + const [editingUser, setEditingUser] = useState(false); + const [name, setName] = useState(""); + const [rateEur, setRateEur] = useState(""); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (!selectedUser) return; + setName(selectedUser.name); + setRateEur(centsToEur(selectedUser.defaultHourlyRateCents)); + setError(null); + }, [selectedUser]); + + const handleSave = async () => { + if (!selectedUser) return; + setError(null); + + const nextName = name.trim(); + if (!nextName) { + setError("Name is required."); + return; + } + + const rate = Math.round(Number.parseFloat(rateEur) * 100); + if (Number.isNaN(rate) || rate < 0) { + setError("Default hourly rate must be a non-negative EUR value."); + return; + } + + setSaving(true); + try { + await updateSelectedUser({ + name: nextName, + defaultHourlyRateCents: rate, + }); + setEditingUser(false); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to update user."); + } finally { + setSaving(false); + } + }; return (
@@ -58,8 +108,51 @@ export function TopBar() { ))} + + + {editingUser && selectedUser && ( +
+
+ + + +
+ {error &&

{error}

} +
+ )}
); } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 996cbc7..4875eec 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -104,6 +104,10 @@ async function sendJson( export const api = { users: { list: () => fetchJson(z.array(UserSchema), "/api/users"), + update: ( + id: number, + body: { name: string; defaultHourlyRateCents: number }, + ) => sendJson(UserSchema, "PUT", `/api/users/${id}`, body), }, projects: { @@ -114,6 +118,15 @@ export const api = { costBudgetCents: number; hourlyRateCents: number | null; }) => sendJson(ProjectSchema, "POST", "/api/projects", body), + update: ( + id: number, + body: { + name: string; + timeBudgetMinutes: number; + costBudgetCents: number; + hourlyRateCents: number | null; + }, + ) => sendJson(ProjectSchema, "PUT", `/api/projects/${id}`, body), }, timeEntries: { diff --git a/frontend/src/state/UserContext.tsx b/frontend/src/state/UserContext.tsx index fc19f34..f43faf2 100644 --- a/frontend/src/state/UserContext.tsx +++ b/frontend/src/state/UserContext.tsx @@ -1,6 +1,7 @@ import { type ReactNode, createContext, + useCallback, useContext, useEffect, useState, @@ -12,6 +13,11 @@ interface UserContextValue { selectedUserId: number | null; setSelectedUserId: (id: number) => void; selectedUser: User | null; + reloadUsers: () => Promise; + updateSelectedUser: (body: { + name: string; + defaultHourlyRateCents: number; + }) => Promise; } const UserContext = createContext(null); @@ -27,10 +33,15 @@ export function UserProvider({ children }: { children: ReactNode }) { }, ); - useEffect(() => { - api.users.list().then(setUsers); + const reloadUsers = useCallback(async () => { + const nextUsers = await api.users.list(); + setUsers(nextUsers); }, []); + useEffect(() => { + reloadUsers(); + }, [reloadUsers]); + // set default user once users are loaded and none is selected useEffect(() => { const first = users[0]; @@ -44,11 +55,29 @@ export function UserProvider({ children }: { children: ReactNode }) { localStorage.setItem(STORAGE_KEY, String(id)); }; + const updateSelectedUser = useCallback( + async (body: { name: string; defaultHourlyRateCents: number }) => { + if (selectedUserId === null) { + throw new Error("No selected user."); + } + const updated = await api.users.update(selectedUserId, body); + setUsers((prev) => prev.map((u) => (u.id === updated.id ? updated : u))); + }, + [selectedUserId], + ); + const selectedUser = users.find((u) => u.id === selectedUserId) ?? null; return ( {children} diff --git a/frontend/tests/TopBar.test.tsx b/frontend/tests/TopBar.test.tsx new file mode 100644 index 0000000..c6787fe --- /dev/null +++ b/frontend/tests/TopBar.test.tsx @@ -0,0 +1,61 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { describe, expect, it, vi } from "vitest"; +import App from "../src/App"; + +const { usersList, usersUpdate } = vi.hoisted(() => ({ + usersList: vi + .fn() + .mockResolvedValue([ + { id: 1, name: "Alice", defaultHourlyRateCents: 9500 }, + ]), + usersUpdate: vi.fn().mockResolvedValue({ + id: 1, + name: "Alice Updated", + defaultHourlyRateCents: 12000, + }), +})); + +vi.mock("../src/lib/api", () => ({ + api: { + users: { list: usersList, update: usersUpdate }, + projects: { list: vi.fn().mockResolvedValue([]) }, + reports: { + weekly: vi.fn().mockResolvedValue({ + days: [], + weekTotalMinutes: 0, + weekTotalCostCents: 0, + }), + }, + timeEntries: { + list: vi.fn().mockResolvedValue([]), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + }, +})); + +describe("TopBar", () => { + it("edits selected user default rate", async () => { + render( + + + , + ); + + await waitFor(() => expect(usersList).toHaveBeenCalled()); + + fireEvent.click(screen.getByRole("button", { name: "Edit user" })); + const rateInput = screen.getByLabelText("Default rate (EUR/h)"); + fireEvent.change(rateInput, { target: { value: "120" } }); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => + expect(usersUpdate).toHaveBeenCalledWith(1, { + name: "Alice", + defaultHourlyRateCents: 12000, + }), + ); + }); +});
Cost logged Time CostActions
+ +