nextjs-hackathon-stack 0.1.28 → 0.1.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/template/.cursor/agents/backend.md +1 -0
- package/template/.cursor/agents/frontend.md +9 -0
- package/template/.cursor/agents/technical-lead.md +1 -0
- package/template/.cursor/rules/architecture.mdc +2 -0
- package/template/.cursor/rules/coding-standards.mdc +7 -0
- package/template/.cursor/rules/forms.mdc +34 -0
- package/template/.husky/pre-commit +1 -0
- package/template/package.json.tmpl +3 -2
- package/template/src/features/auth/__tests__/login-form.test.tsx +12 -1
- package/template/src/features/auth/components/login-form.tsx +6 -1
- package/template/src/features/todos/__tests__/add-todo-form.test.tsx +32 -2
- package/template/src/features/todos/__tests__/todo-list.test.tsx +56 -7
- package/template/src/features/todos/__tests__/todos.action.test.ts +60 -14
- package/template/src/features/todos/actions/todos.action.ts +33 -15
- package/template/src/features/todos/components/add-todo-form.tsx +8 -2
- package/template/src/features/todos/components/todo-list.tsx +11 -2
- package/template/src/shared/components/providers.tsx +7 -1
- package/template/src/shared/lib/action-result.ts +3 -0
package/dist/index.js
CHANGED
|
@@ -122,7 +122,7 @@ async function scaffold(projectName, skipInstall) {
|
|
|
122
122
|
} catch {
|
|
123
123
|
spinner2.stop("shadcn/ui init failed \u2014 run manually: npx shadcn@latest init");
|
|
124
124
|
}
|
|
125
|
-
const components = ["button", "input", "card", "form", "label"];
|
|
125
|
+
const components = ["button", "input", "card", "form", "label", "sonner"];
|
|
126
126
|
for (const component of components) {
|
|
127
127
|
spinner2.start(`Adding shadcn/ui component: ${component}`);
|
|
128
128
|
try {
|
package/package.json
CHANGED
|
@@ -81,3 +81,4 @@ export async function createUser(formData: FormData) {
|
|
|
81
81
|
- Never expose `DATABASE_URL` to the browser
|
|
82
82
|
- RLS policies are required before any table goes to production
|
|
83
83
|
- Validate all mutation inputs with Zod on the server side
|
|
84
|
+
- Server actions that mutate data must return `ActionResult` from `@/shared/lib/action-result` — never return `void`. This enables the frontend to show toast feedback.
|
|
@@ -39,6 +39,15 @@ export function InteractiveComponent() {
|
|
|
39
39
|
}
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
+
## Toast Feedback
|
|
43
|
+
|
|
44
|
+
All user-initiated actions that modify the database must show a toast (success or error) via `sonner`. Never let a mutation complete silently.
|
|
45
|
+
|
|
46
|
+
- Call `toast.success(message)` on success, `toast.error(message)` on failure
|
|
47
|
+
- For direct action calls: read the returned `ActionResult` and show the toast immediately
|
|
48
|
+
- For `useActionState` forms: use a `useEffect` on state changes to trigger toasts
|
|
49
|
+
- Exception: if the action redirects to another page, no success toast is needed
|
|
50
|
+
|
|
42
51
|
## Guardrails
|
|
43
52
|
- Write RTL tests for every component
|
|
44
53
|
- Verify a11y before marking work done
|
|
@@ -54,6 +54,7 @@ The technical-lead is the orchestrator. Every request passes through here first.
|
|
|
54
54
|
- [ ] Proper layer separation
|
|
55
55
|
- [ ] Edge runtime on all AI routes (Risk 3)
|
|
56
56
|
- [ ] TanStack Query is NOT used for mutations (Risk 1)
|
|
57
|
+
- [ ] Every DB mutation shows toast feedback to the user (success or error via `sonner`)
|
|
57
58
|
|
|
58
59
|
## Guardrails
|
|
59
60
|
- Reject any PR without test coverage
|
|
@@ -40,3 +40,5 @@ If two features share logic, move it to `shared/`.
|
|
|
40
40
|
- Always `"use server"` at top of file
|
|
41
41
|
- Validate input with Zod before any business logic
|
|
42
42
|
- Return typed result objects, use `redirect()` for navigation
|
|
43
|
+
- Actions that mutate data must return `ActionResult` from `@/shared/lib/action-result` — never return `void`
|
|
44
|
+
- The calling component is responsible for showing `toast.success()` / `toast.error()` based on the result
|
|
@@ -51,3 +51,10 @@ Never write implementation before tests exist.
|
|
|
51
51
|
- Prefer `type` over `interface` for data shapes
|
|
52
52
|
- Use `satisfies` for type-checked literals
|
|
53
53
|
- Use `as const` for immutable literals
|
|
54
|
+
|
|
55
|
+
## Pre-commit Quality Gates
|
|
56
|
+
Every commit must pass two sequential gates:
|
|
57
|
+
1. **lint-staged** — runs `eslint --max-warnings 0` on staged `.ts/.tsx` files. Any lint warning or error blocks the commit immediately (before the slower test suite runs).
|
|
58
|
+
2. **Full test suite** — runs `vitest run --coverage` with 100% branch/function/line/statement coverage required.
|
|
59
|
+
|
|
60
|
+
Code that fails either gate cannot be committed.
|
|
@@ -57,3 +57,37 @@ export async function myAction(_prev: State, formData: FormData): Promise<State>
|
|
|
57
57
|
redirect("/success");
|
|
58
58
|
}
|
|
59
59
|
```
|
|
60
|
+
|
|
61
|
+
## Toast Feedback
|
|
62
|
+
|
|
63
|
+
Every action that affects the database MUST show a toast to the user. No mutation should complete silently.
|
|
64
|
+
|
|
65
|
+
- Use `toast.success(message)` for successful operations
|
|
66
|
+
- Use `toast.error(message)` for failed operations
|
|
67
|
+
- Import `toast` from `sonner`
|
|
68
|
+
- Server actions that mutate data must return `ActionResult` from `@/shared/lib/action-result` (not `void`)
|
|
69
|
+
- Exception: if the action redirects to another page on success, no success toast is needed (the user sees the new page)
|
|
70
|
+
|
|
71
|
+
### Pattern A — Direct action call (button onClick, non-`useActionState` forms)
|
|
72
|
+
```typescript
|
|
73
|
+
import { toast } from "sonner";
|
|
74
|
+
import type { ActionResult } from "@/shared/lib/action-result";
|
|
75
|
+
|
|
76
|
+
startTransition(async () => {
|
|
77
|
+
const result = await myAction(id);
|
|
78
|
+
if (result.status === "success") toast.success(result.message);
|
|
79
|
+
else toast.error(result.message);
|
|
80
|
+
});
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Pattern B — `useActionState` forms
|
|
84
|
+
```typescript
|
|
85
|
+
import { useEffect } from "react";
|
|
86
|
+
import { toast } from "sonner";
|
|
87
|
+
|
|
88
|
+
const [state, formAction] = useActionState(myAction, { status: "idle" });
|
|
89
|
+
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (state.status === "error") toast.error(state.message);
|
|
92
|
+
}, [state]);
|
|
93
|
+
```
|
|
@@ -39,7 +39,8 @@
|
|
|
39
39
|
"class-variance-authority": "^0.7",
|
|
40
40
|
"@radix-ui/react-slot": "^1",
|
|
41
41
|
"@radix-ui/react-label": "^2",
|
|
42
|
-
"lucide-react": "^0.475"
|
|
42
|
+
"lucide-react": "^0.475",
|
|
43
|
+
"sonner": "^2"
|
|
43
44
|
},
|
|
44
45
|
"devDependencies": {
|
|
45
46
|
"typescript": "^5",
|
|
@@ -72,6 +73,6 @@
|
|
|
72
73
|
"lint-staged": "^15"
|
|
73
74
|
},
|
|
74
75
|
"lint-staged": {
|
|
75
|
-
"*.{ts,tsx}": ["
|
|
76
|
+
"*.{ts,tsx}": ["eslint --max-warnings 0"]
|
|
76
77
|
}
|
|
77
78
|
}
|
|
@@ -4,6 +4,16 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
4
4
|
|
|
5
5
|
import { LoginForm } from "../components/login-form";
|
|
6
6
|
|
|
7
|
+
const mockToastError = vi.fn();
|
|
8
|
+
|
|
9
|
+
vi.mock("sonner", () => ({
|
|
10
|
+
toast: {
|
|
11
|
+
success: vi.fn(),
|
|
12
|
+
error: (...args: unknown[]): unknown => mockToastError(...args),
|
|
13
|
+
},
|
|
14
|
+
Toaster: () => null,
|
|
15
|
+
}));
|
|
16
|
+
|
|
7
17
|
const mockLoginAction = vi.fn().mockResolvedValue({ status: "idle" });
|
|
8
18
|
|
|
9
19
|
vi.mock("../actions/login.action", () => ({
|
|
@@ -75,7 +85,7 @@ describe("LoginForm", () => {
|
|
|
75
85
|
});
|
|
76
86
|
});
|
|
77
87
|
|
|
78
|
-
it("displays server-side error message when action returns error", async () => {
|
|
88
|
+
it("displays server-side error message and toast when action returns error", async () => {
|
|
79
89
|
// Arrange
|
|
80
90
|
mockLoginAction.mockResolvedValue({ status: "error", message: "Invalid credentials" });
|
|
81
91
|
const user = userEvent.setup();
|
|
@@ -90,6 +100,7 @@ describe("LoginForm", () => {
|
|
|
90
100
|
await waitFor(() => {
|
|
91
101
|
expect(screen.getByRole("alert")).toBeInTheDocument();
|
|
92
102
|
expect(screen.getByText(/invalid credentials/i)).toBeInTheDocument();
|
|
103
|
+
expect(mockToastError).toHaveBeenCalledWith("Invalid credentials");
|
|
93
104
|
});
|
|
94
105
|
});
|
|
95
106
|
});
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { zodResolver } from "@hookform/resolvers/zod";
|
|
4
|
-
import { useActionState, useTransition } from "react";
|
|
4
|
+
import { useActionState, useEffect, useTransition } from "react";
|
|
5
5
|
import { useForm } from "react-hook-form";
|
|
6
|
+
import { toast } from "sonner";
|
|
6
7
|
import { z } from "zod";
|
|
7
8
|
|
|
8
9
|
import { loginAction, type LoginActionState } from "../actions/login.action";
|
|
@@ -31,6 +32,10 @@ export function LoginForm() {
|
|
|
31
32
|
formState: { errors },
|
|
32
33
|
} = useForm<FormValues>({ resolver: zodResolver(schema) });
|
|
33
34
|
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (state.status === "error") toast.error(state.message);
|
|
37
|
+
}, [state]);
|
|
38
|
+
|
|
34
39
|
const onSubmit = handleSubmit((data) => {
|
|
35
40
|
const fd = new FormData();
|
|
36
41
|
fd.set("email", data.email);
|
|
@@ -4,7 +4,18 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
4
4
|
|
|
5
5
|
import { AddTodoForm } from "../components/add-todo-form";
|
|
6
6
|
|
|
7
|
-
const
|
|
7
|
+
const mockToastSuccess = vi.fn();
|
|
8
|
+
const mockToastError = vi.fn();
|
|
9
|
+
|
|
10
|
+
vi.mock("sonner", () => ({
|
|
11
|
+
toast: {
|
|
12
|
+
success: (...args: unknown[]): unknown => mockToastSuccess(...args),
|
|
13
|
+
error: (...args: unknown[]): unknown => mockToastError(...args),
|
|
14
|
+
},
|
|
15
|
+
Toaster: () => null,
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
const mockAdd = vi.fn();
|
|
8
19
|
|
|
9
20
|
vi.mock("../actions/todos.action", () => ({
|
|
10
21
|
addTodoAction: (...args: unknown[]): unknown => mockAdd(...args),
|
|
@@ -26,6 +37,7 @@ describe("AddTodoForm", () => {
|
|
|
26
37
|
|
|
27
38
|
it("llama a addTodoAction con el título al enviar el formulario", async () => {
|
|
28
39
|
// Arrange
|
|
40
|
+
mockAdd.mockResolvedValue({ status: "success", message: "Tarea agregada" });
|
|
29
41
|
const user = userEvent.setup();
|
|
30
42
|
render(<AddTodoForm />);
|
|
31
43
|
|
|
@@ -39,8 +51,9 @@ describe("AddTodoForm", () => {
|
|
|
39
51
|
});
|
|
40
52
|
});
|
|
41
53
|
|
|
42
|
-
it("limpia el input después de enviar", async () => {
|
|
54
|
+
it("muestra toast success y limpia el input después de enviar con éxito", async () => {
|
|
43
55
|
// Arrange
|
|
56
|
+
mockAdd.mockResolvedValue({ status: "success", message: "Tarea agregada" });
|
|
44
57
|
const user = userEvent.setup();
|
|
45
58
|
render(<AddTodoForm />);
|
|
46
59
|
const input = screen.getByRole("textbox", { name: /nueva tarea/i });
|
|
@@ -51,10 +64,27 @@ describe("AddTodoForm", () => {
|
|
|
51
64
|
|
|
52
65
|
// Assert
|
|
53
66
|
await waitFor(() => {
|
|
67
|
+
expect(mockToastSuccess).toHaveBeenCalledWith("Tarea agregada");
|
|
54
68
|
expect(input).toHaveValue("");
|
|
55
69
|
});
|
|
56
70
|
});
|
|
57
71
|
|
|
72
|
+
it("muestra toast error cuando la acción falla", async () => {
|
|
73
|
+
// Arrange
|
|
74
|
+
mockAdd.mockResolvedValue({ status: "error", message: "Error al agregar la tarea" });
|
|
75
|
+
const user = userEvent.setup();
|
|
76
|
+
render(<AddTodoForm />);
|
|
77
|
+
|
|
78
|
+
// Act
|
|
79
|
+
await user.type(screen.getByRole("textbox", { name: /nueva tarea/i }), "Tarea fallida");
|
|
80
|
+
await user.click(screen.getByRole("button", { name: /agregar/i }));
|
|
81
|
+
|
|
82
|
+
// Assert
|
|
83
|
+
await waitFor(() => {
|
|
84
|
+
expect(mockToastError).toHaveBeenCalledWith("Error al agregar la tarea");
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
58
88
|
it("no envía el formulario si el input está vacío (required)", async () => {
|
|
59
89
|
// Arrange
|
|
60
90
|
const user = userEvent.setup();
|
|
@@ -1,11 +1,22 @@
|
|
|
1
|
-
import { render, screen } from "@testing-library/react";
|
|
1
|
+
import { render, screen, waitFor } from "@testing-library/react";
|
|
2
2
|
import userEvent from "@testing-library/user-event";
|
|
3
3
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
4
4
|
|
|
5
5
|
import { TodoList } from "../components/todo-list";
|
|
6
6
|
|
|
7
|
-
const
|
|
8
|
-
const
|
|
7
|
+
const mockToastSuccess = vi.fn();
|
|
8
|
+
const mockToastError = vi.fn();
|
|
9
|
+
|
|
10
|
+
vi.mock("sonner", () => ({
|
|
11
|
+
toast: {
|
|
12
|
+
success: (...args: unknown[]): unknown => mockToastSuccess(...args),
|
|
13
|
+
error: (...args: unknown[]): unknown => mockToastError(...args),
|
|
14
|
+
},
|
|
15
|
+
Toaster: () => null,
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
const mockToggle = vi.fn();
|
|
19
|
+
const mockDelete = vi.fn();
|
|
9
20
|
|
|
10
21
|
vi.mock("../actions/todos.action", () => ({
|
|
11
22
|
toggleTodoAction: (...args: unknown[]): unknown => mockToggle(...args),
|
|
@@ -59,8 +70,9 @@ describe("TodoList", () => {
|
|
|
59
70
|
expect(screen.getByText("Comprar leche")).toHaveClass("line-through");
|
|
60
71
|
});
|
|
61
72
|
|
|
62
|
-
it("llama a toggleTodoAction al cambiar el checkbox", async () => {
|
|
73
|
+
it("llama a toggleTodoAction al cambiar el checkbox y muestra toast success", async () => {
|
|
63
74
|
// Arrange
|
|
75
|
+
mockToggle.mockResolvedValue({ status: "success", message: "Tarea actualizada" });
|
|
64
76
|
const user = userEvent.setup();
|
|
65
77
|
render(<TodoList items={[baseTodo]} />);
|
|
66
78
|
|
|
@@ -68,11 +80,46 @@ describe("TodoList", () => {
|
|
|
68
80
|
await user.click(screen.getByRole("checkbox"));
|
|
69
81
|
|
|
70
82
|
// Assert
|
|
71
|
-
|
|
83
|
+
await waitFor(() => {
|
|
84
|
+
expect(mockToggle).toHaveBeenCalledWith("1");
|
|
85
|
+
expect(mockToastSuccess).toHaveBeenCalledWith("Tarea actualizada");
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("muestra toast error cuando toggleTodoAction falla", async () => {
|
|
90
|
+
// Arrange
|
|
91
|
+
mockToggle.mockResolvedValue({ status: "error", message: "Error al actualizar la tarea" });
|
|
92
|
+
const user = userEvent.setup();
|
|
93
|
+
render(<TodoList items={[baseTodo]} />);
|
|
94
|
+
|
|
95
|
+
// Act
|
|
96
|
+
await user.click(screen.getByRole("checkbox"));
|
|
97
|
+
|
|
98
|
+
// Assert
|
|
99
|
+
await waitFor(() => {
|
|
100
|
+
expect(mockToastError).toHaveBeenCalledWith("Error al actualizar la tarea");
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("llama a deleteTodoAction al pulsar Eliminar y muestra toast success", async () => {
|
|
105
|
+
// Arrange
|
|
106
|
+
mockDelete.mockResolvedValue({ status: "success", message: "Tarea eliminada" });
|
|
107
|
+
const user = userEvent.setup();
|
|
108
|
+
render(<TodoList items={[baseTodo]} />);
|
|
109
|
+
|
|
110
|
+
// Act
|
|
111
|
+
await user.click(screen.getByRole("button", { name: /eliminar/i }));
|
|
112
|
+
|
|
113
|
+
// Assert
|
|
114
|
+
await waitFor(() => {
|
|
115
|
+
expect(mockDelete).toHaveBeenCalledWith("1");
|
|
116
|
+
expect(mockToastSuccess).toHaveBeenCalledWith("Tarea eliminada");
|
|
117
|
+
});
|
|
72
118
|
});
|
|
73
119
|
|
|
74
|
-
it("
|
|
120
|
+
it("muestra toast error cuando deleteTodoAction falla", async () => {
|
|
75
121
|
// Arrange
|
|
122
|
+
mockDelete.mockResolvedValue({ status: "error", message: "Error al eliminar la tarea" });
|
|
76
123
|
const user = userEvent.setup();
|
|
77
124
|
render(<TodoList items={[baseTodo]} />);
|
|
78
125
|
|
|
@@ -80,6 +127,8 @@ describe("TodoList", () => {
|
|
|
80
127
|
await user.click(screen.getByRole("button", { name: /eliminar/i }));
|
|
81
128
|
|
|
82
129
|
// Assert
|
|
83
|
-
|
|
130
|
+
await waitFor(() => {
|
|
131
|
+
expect(mockToastError).toHaveBeenCalledWith("Error al eliminar la tarea");
|
|
132
|
+
});
|
|
84
133
|
});
|
|
85
134
|
});
|
|
@@ -37,20 +37,21 @@ describe("addTodoAction", () => {
|
|
|
37
37
|
vi.clearAllMocks();
|
|
38
38
|
});
|
|
39
39
|
|
|
40
|
-
it("
|
|
40
|
+
it("retorna error si el título está vacío", async () => {
|
|
41
41
|
// Arrange
|
|
42
42
|
const { addTodoAction } = await import("../actions/todos.action");
|
|
43
43
|
const fd = new FormData();
|
|
44
44
|
fd.set("title", " ");
|
|
45
45
|
|
|
46
46
|
// Act
|
|
47
|
-
await addTodoAction(fd);
|
|
47
|
+
const result = await addTodoAction(fd);
|
|
48
48
|
|
|
49
49
|
// Assert
|
|
50
|
+
expect(result.status).toBe("error");
|
|
50
51
|
expect(mockInsert).not.toHaveBeenCalled();
|
|
51
52
|
});
|
|
52
53
|
|
|
53
|
-
it("
|
|
54
|
+
it("retorna error si no hay usuario autenticado", async () => {
|
|
54
55
|
// Arrange
|
|
55
56
|
mockGetUser.mockResolvedValue({ data: { user: null } });
|
|
56
57
|
const { addTodoAction } = await import("../actions/todos.action");
|
|
@@ -58,13 +59,14 @@ describe("addTodoAction", () => {
|
|
|
58
59
|
fd.set("title", "Mi tarea");
|
|
59
60
|
|
|
60
61
|
// Act
|
|
61
|
-
await addTodoAction(fd);
|
|
62
|
+
const result = await addTodoAction(fd);
|
|
62
63
|
|
|
63
64
|
// Assert
|
|
65
|
+
expect(result.status).toBe("error");
|
|
64
66
|
expect(mockInsert).not.toHaveBeenCalled();
|
|
65
67
|
});
|
|
66
68
|
|
|
67
|
-
it("inserta la tarea
|
|
69
|
+
it("inserta la tarea, llama a revalidatePath y retorna success cuando el usuario existe", async () => {
|
|
68
70
|
// Arrange
|
|
69
71
|
mockGetUser.mockResolvedValue({ data: { user: { id: "u1" } } });
|
|
70
72
|
mockInsert.mockResolvedValue(undefined);
|
|
@@ -73,24 +75,41 @@ describe("addTodoAction", () => {
|
|
|
73
75
|
fd.set("title", "Nueva tarea");
|
|
74
76
|
|
|
75
77
|
// Act
|
|
76
|
-
await addTodoAction(fd);
|
|
78
|
+
const result = await addTodoAction(fd);
|
|
77
79
|
|
|
78
80
|
// Assert
|
|
81
|
+
expect(result.status).toBe("success");
|
|
79
82
|
expect(mockInsert).toHaveBeenCalledWith({ userId: "u1", title: "Nueva tarea" });
|
|
80
83
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/");
|
|
81
84
|
});
|
|
82
85
|
|
|
83
|
-
it("
|
|
86
|
+
it("retorna error si el campo title no está presente", async () => {
|
|
84
87
|
// Arrange
|
|
85
88
|
const { addTodoAction } = await import("../actions/todos.action");
|
|
86
89
|
const fd = new FormData();
|
|
87
90
|
|
|
88
91
|
// Act
|
|
89
|
-
await addTodoAction(fd);
|
|
92
|
+
const result = await addTodoAction(fd);
|
|
90
93
|
|
|
91
94
|
// Assert
|
|
95
|
+
expect(result.status).toBe("error");
|
|
92
96
|
expect(mockInsert).not.toHaveBeenCalled();
|
|
93
97
|
});
|
|
98
|
+
|
|
99
|
+
it("retorna error si la inserción lanza una excepción", async () => {
|
|
100
|
+
// Arrange
|
|
101
|
+
mockGetUser.mockResolvedValue({ data: { user: { id: "u1" } } });
|
|
102
|
+
mockInsert.mockRejectedValue(new Error("DB error"));
|
|
103
|
+
const { addTodoAction } = await import("../actions/todos.action");
|
|
104
|
+
const fd = new FormData();
|
|
105
|
+
fd.set("title", "Tarea fallida");
|
|
106
|
+
|
|
107
|
+
// Act
|
|
108
|
+
const result = await addTodoAction(fd);
|
|
109
|
+
|
|
110
|
+
// Assert
|
|
111
|
+
expect(result.status).toBe("error");
|
|
112
|
+
});
|
|
94
113
|
});
|
|
95
114
|
|
|
96
115
|
describe("toggleTodoAction", () => {
|
|
@@ -98,31 +117,45 @@ describe("toggleTodoAction", () => {
|
|
|
98
117
|
vi.clearAllMocks();
|
|
99
118
|
});
|
|
100
119
|
|
|
101
|
-
it("
|
|
120
|
+
it("retorna error si la tarea no existe", async () => {
|
|
102
121
|
// Arrange
|
|
103
122
|
mockSelectWhere.mockResolvedValue([]);
|
|
104
123
|
const { toggleTodoAction } = await import("../actions/todos.action");
|
|
105
124
|
|
|
106
125
|
// Act
|
|
107
|
-
await toggleTodoAction("non-existent-id");
|
|
126
|
+
const result = await toggleTodoAction("non-existent-id");
|
|
108
127
|
|
|
109
128
|
// Assert
|
|
129
|
+
expect(result.status).toBe("error");
|
|
110
130
|
expect(mockUpdate).not.toHaveBeenCalled();
|
|
111
131
|
});
|
|
112
132
|
|
|
113
|
-
it("invierte completed
|
|
133
|
+
it("invierte completed, llama a revalidatePath y retorna success", async () => {
|
|
114
134
|
// Arrange
|
|
115
135
|
mockSelectWhere.mockResolvedValue([{ id: "1", completed: false }]);
|
|
116
136
|
mockUpdate.mockResolvedValue(undefined);
|
|
117
137
|
const { toggleTodoAction } = await import("../actions/todos.action");
|
|
118
138
|
|
|
119
139
|
// Act
|
|
120
|
-
await toggleTodoAction("1");
|
|
140
|
+
const result = await toggleTodoAction("1");
|
|
121
141
|
|
|
122
142
|
// Assert
|
|
143
|
+
expect(result.status).toBe("success");
|
|
123
144
|
expect(mockUpdate).toHaveBeenCalled();
|
|
124
145
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/");
|
|
125
146
|
});
|
|
147
|
+
|
|
148
|
+
it("retorna error si la actualización lanza una excepción", async () => {
|
|
149
|
+
// Arrange
|
|
150
|
+
mockSelectWhere.mockRejectedValue(new Error("DB error"));
|
|
151
|
+
const { toggleTodoAction } = await import("../actions/todos.action");
|
|
152
|
+
|
|
153
|
+
// Act
|
|
154
|
+
const result = await toggleTodoAction("1");
|
|
155
|
+
|
|
156
|
+
// Assert
|
|
157
|
+
expect(result.status).toBe("error");
|
|
158
|
+
});
|
|
126
159
|
});
|
|
127
160
|
|
|
128
161
|
describe("deleteTodoAction", () => {
|
|
@@ -130,16 +163,29 @@ describe("deleteTodoAction", () => {
|
|
|
130
163
|
vi.clearAllMocks();
|
|
131
164
|
});
|
|
132
165
|
|
|
133
|
-
it("elimina la tarea
|
|
166
|
+
it("elimina la tarea, llama a revalidatePath y retorna success", async () => {
|
|
134
167
|
// Arrange
|
|
135
168
|
mockDelete.mockResolvedValue(undefined);
|
|
136
169
|
const { deleteTodoAction } = await import("../actions/todos.action");
|
|
137
170
|
|
|
138
171
|
// Act
|
|
139
|
-
await deleteTodoAction("1");
|
|
172
|
+
const result = await deleteTodoAction("1");
|
|
140
173
|
|
|
141
174
|
// Assert
|
|
175
|
+
expect(result.status).toBe("success");
|
|
142
176
|
expect(mockDelete).toHaveBeenCalled();
|
|
143
177
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/");
|
|
144
178
|
});
|
|
179
|
+
|
|
180
|
+
it("retorna error si la eliminación lanza una excepción", async () => {
|
|
181
|
+
// Arrange
|
|
182
|
+
mockDelete.mockRejectedValue(new Error("DB error"));
|
|
183
|
+
const { deleteTodoAction } = await import("../actions/todos.action");
|
|
184
|
+
|
|
185
|
+
// Act
|
|
186
|
+
const result = await deleteTodoAction("1");
|
|
187
|
+
|
|
188
|
+
// Assert
|
|
189
|
+
expect(result.status).toBe("error");
|
|
190
|
+
});
|
|
145
191
|
});
|
|
@@ -6,30 +6,48 @@ import { revalidatePath } from "next/cache";
|
|
|
6
6
|
import { db } from "@/shared/db";
|
|
7
7
|
import { todos } from "@/shared/db/schema";
|
|
8
8
|
import { createClient } from "@/shared/lib/supabase/server";
|
|
9
|
+
import type { ActionResult } from "@/shared/lib/action-result";
|
|
9
10
|
|
|
10
|
-
export async function addTodoAction(formData: FormData): Promise<
|
|
11
|
+
export async function addTodoAction(formData: FormData): Promise<ActionResult> {
|
|
11
12
|
const title = formData.get("title");
|
|
12
|
-
if (!title || typeof title !== "string" || title.trim() === "")
|
|
13
|
+
if (!title || typeof title !== "string" || title.trim() === "") {
|
|
14
|
+
return { status: "error", message: "El título no puede estar vacío" };
|
|
15
|
+
}
|
|
13
16
|
|
|
14
17
|
const supabase = await createClient();
|
|
15
18
|
const {
|
|
16
19
|
data: { user },
|
|
17
20
|
} = await supabase.auth.getUser();
|
|
18
|
-
if (!user) return;
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
if (!user) return { status: "error", message: "No autenticado" };
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
await db.insert(todos).values({ userId: user.id, title: title.trim() });
|
|
25
|
+
revalidatePath("/");
|
|
26
|
+
return { status: "success", message: "Tarea agregada" };
|
|
27
|
+
} catch {
|
|
28
|
+
return { status: "error", message: "Error al agregar la tarea" };
|
|
29
|
+
}
|
|
22
30
|
}
|
|
23
31
|
|
|
24
|
-
export async function toggleTodoAction(id: string): Promise<
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
32
|
+
export async function toggleTodoAction(id: string): Promise<ActionResult> {
|
|
33
|
+
try {
|
|
34
|
+
const [todo] = await db.select().from(todos).where(eq(todos.id, id));
|
|
35
|
+
if (!todo) return { status: "error", message: "Tarea no encontrada" };
|
|
36
|
+
|
|
37
|
+
await db.update(todos).set({ completed: !todo.completed }).where(eq(todos.id, id));
|
|
38
|
+
revalidatePath("/");
|
|
39
|
+
return { status: "success", message: "Tarea actualizada" };
|
|
40
|
+
} catch {
|
|
41
|
+
return { status: "error", message: "Error al actualizar la tarea" };
|
|
42
|
+
}
|
|
30
43
|
}
|
|
31
44
|
|
|
32
|
-
export async function deleteTodoAction(id: string): Promise<
|
|
33
|
-
|
|
34
|
-
|
|
45
|
+
export async function deleteTodoAction(id: string): Promise<ActionResult> {
|
|
46
|
+
try {
|
|
47
|
+
await db.delete(todos).where(eq(todos.id, id));
|
|
48
|
+
revalidatePath("/");
|
|
49
|
+
return { status: "success", message: "Tarea eliminada" };
|
|
50
|
+
} catch {
|
|
51
|
+
return { status: "error", message: "Error al eliminar la tarea" };
|
|
52
|
+
}
|
|
35
53
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useRef, useTransition } from "react";
|
|
4
|
+
import { toast } from "sonner";
|
|
4
5
|
|
|
5
6
|
import { addTodoAction } from "../actions/todos.action";
|
|
6
7
|
|
|
@@ -14,8 +15,13 @@ export function AddTodoForm() {
|
|
|
14
15
|
e.preventDefault();
|
|
15
16
|
const formData = new FormData(e.currentTarget);
|
|
16
17
|
startTransition(async () => {
|
|
17
|
-
await addTodoAction(formData);
|
|
18
|
-
if (
|
|
18
|
+
const result = await addTodoAction(formData);
|
|
19
|
+
if (result.status === "success") {
|
|
20
|
+
toast.success(result.message);
|
|
21
|
+
if (inputRef.current) inputRef.current.value = "";
|
|
22
|
+
} else {
|
|
23
|
+
toast.error(result.message);
|
|
24
|
+
}
|
|
19
25
|
});
|
|
20
26
|
}
|
|
21
27
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useTransition } from "react";
|
|
4
|
+
import { toast } from "sonner";
|
|
4
5
|
|
|
5
6
|
import { deleteTodoAction, toggleTodoAction } from "../actions/todos.action";
|
|
6
7
|
|
|
@@ -35,7 +36,11 @@ export function TodoList({ items }: TodoListProps) {
|
|
|
35
36
|
checked={todo.completed}
|
|
36
37
|
disabled={isPending}
|
|
37
38
|
onChange={() => {
|
|
38
|
-
startTransition(() => {
|
|
39
|
+
startTransition(async () => {
|
|
40
|
+
const result = await toggleTodoAction(todo.id);
|
|
41
|
+
if (result.status === "error") toast.error(result.message);
|
|
42
|
+
else toast.success(result.message);
|
|
43
|
+
});
|
|
39
44
|
}}
|
|
40
45
|
aria-label={`Marcar "${todo.title}" como completada`}
|
|
41
46
|
/>
|
|
@@ -48,7 +53,11 @@ export function TodoList({ items }: TodoListProps) {
|
|
|
48
53
|
type="button"
|
|
49
54
|
disabled={isPending}
|
|
50
55
|
onClick={() => {
|
|
51
|
-
startTransition(() => {
|
|
56
|
+
startTransition(async () => {
|
|
57
|
+
const result = await deleteTodoAction(todo.id);
|
|
58
|
+
if (result.status === "error") toast.error(result.message);
|
|
59
|
+
else toast.success(result.message);
|
|
60
|
+
});
|
|
52
61
|
}}
|
|
53
62
|
className="rounded px-2 py-1 text-sm text-red-500 hover:bg-red-50"
|
|
54
63
|
aria-label={`Eliminar "${todo.title}"`}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
4
4
|
import { useState } from "react";
|
|
5
|
+
import { Toaster } from "sonner";
|
|
5
6
|
|
|
6
7
|
export function Providers({ children }: { children: React.ReactNode }) {
|
|
7
8
|
const [queryClient] = useState(
|
|
@@ -15,5 +16,10 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
|
|
15
16
|
})
|
|
16
17
|
);
|
|
17
18
|
|
|
18
|
-
return
|
|
19
|
+
return (
|
|
20
|
+
<QueryClientProvider client={queryClient}>
|
|
21
|
+
{children}
|
|
22
|
+
<Toaster />
|
|
23
|
+
</QueryClientProvider>
|
|
24
|
+
);
|
|
19
25
|
}
|