Skip to content

Commit 57ec65d

Browse files
authored
Feature/deleteDialog (#301)
2 parents 8a7f8d5 + b592ba9 commit 57ec65d

File tree

7 files changed

+249
-13
lines changed

7 files changed

+249
-13
lines changed
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import { createRemixStub } from "@remix-run/testing";
2+
import { render, screen, waitFor } from "@testing-library/react";
3+
import { expect, test } from "vitest";
4+
import "@testing-library/jest-dom";
5+
import { userEvent } from "@testing-library/user-event";
6+
import { authenticator } from "~/utils/auth.server";
7+
import { prisma } from "~/utils/prisma";
8+
import UserProfile, { loader, action } from "./index";
9+
10+
vi.mock("~/utils/auth.server", () => ({
11+
authenticator: {
12+
isAuthenticated: vi.fn(),
13+
},
14+
}));
15+
16+
describe("UserProfile", () => {
17+
beforeEach(async () => {
18+
await prisma.user.create({
19+
data: {
20+
userName: "testuser",
21+
displayName: "Test User",
22+
23+
icon: "https://example.com/icon.jpg",
24+
profile: "This is a test profile",
25+
pages: {
26+
create: [
27+
{
28+
title: "Public Page",
29+
slug: "public-page",
30+
isPublished: true,
31+
content: "This is a test content",
32+
},
33+
{
34+
title: "Private Page",
35+
slug: "private-page",
36+
isPublished: false,
37+
content: "This is a test content2",
38+
},
39+
{
40+
title: "Archived Page",
41+
slug: "archived-page",
42+
isArchived: true,
43+
content: "This is a test content3",
44+
},
45+
],
46+
},
47+
},
48+
include: { pages: true },
49+
});
50+
});
51+
52+
test("loader returns correct data and menu is displayed for authenticated owner", async () => {
53+
// @ts-ignore
54+
vi.mocked(authenticator.isAuthenticated).mockResolvedValue({
55+
id: 1,
56+
userName: "testuser",
57+
});
58+
const RemixStub = createRemixStub([
59+
{
60+
path: "/:userName",
61+
Component: UserProfile,
62+
loader,
63+
},
64+
]);
65+
66+
render(<RemixStub initialEntries={["/testuser"]} />);
67+
68+
expect(await screen.findByText("Test User")).toBeInTheDocument();
69+
expect(
70+
await screen.findByText("This is a test profile"),
71+
).toBeInTheDocument();
72+
expect(await screen.findByText("Public Page")).toBeInTheDocument();
73+
expect(await screen.findByText("Private Page")).toBeInTheDocument();
74+
expect(await screen.queryByText("Archived Page")).not.toBeInTheDocument();
75+
const menuButtons = await screen.findAllByLabelText("More options");
76+
expect(menuButtons.length).toBeGreaterThan(0);
77+
78+
await userEvent.click(menuButtons[0]);
79+
80+
expect(await screen.findByText("Edit")).toBeInTheDocument();
81+
expect(await screen.findByText("Make Private")).toBeInTheDocument();
82+
expect(await screen.findByText("Delete")).toBeInTheDocument();
83+
});
84+
85+
test("loader returns correct data and menu is not displayed for unauthenticated visitor", async () => {
86+
// @ts-ignore
87+
vi.mocked(authenticator.isAuthenticated).mockResolvedValue(null);
88+
const RemixStub = createRemixStub([
89+
{
90+
path: "/:userName",
91+
Component: UserProfile,
92+
loader,
93+
},
94+
]);
95+
render(<RemixStub initialEntries={["/testuser"]} />);
96+
97+
expect(await screen.findByText("Test User")).toBeInTheDocument();
98+
expect(
99+
await screen.findByText("This is a test profile"),
100+
).toBeInTheDocument();
101+
expect(await screen.findByText("Public Page")).toBeInTheDocument();
102+
expect(await screen.queryByText("Private Page")).not.toBeInTheDocument();
103+
expect(await screen.queryByText("Archived Page")).not.toBeInTheDocument();
104+
expect(
105+
await screen.queryByLabelText("More options"),
106+
).not.toBeInTheDocument();
107+
});
108+
109+
test("action handles togglePublish correctly", async () => {
110+
// @ts-ignore
111+
vi.mocked(authenticator.isAuthenticated).mockResolvedValue({
112+
id: 1,
113+
userName: "testuser",
114+
});
115+
const RemixStub = createRemixStub([
116+
{
117+
path: "/:userName",
118+
Component: UserProfile,
119+
loader,
120+
action,
121+
},
122+
]);
123+
render(<RemixStub initialEntries={["/testuser"]} />);
124+
125+
const menuButtons = await screen.findAllByLabelText("More options");
126+
expect(menuButtons.length).toBeGreaterThan(0);
127+
128+
await userEvent.click(menuButtons[0]);
129+
130+
expect(await screen.findByText("Edit")).toBeInTheDocument();
131+
expect(await screen.findByText("Make Private")).toBeInTheDocument();
132+
await userEvent.click(await screen.findByText("Make Private"));
133+
134+
waitFor(() => {
135+
userEvent.click(menuButtons[0]);
136+
expect(screen.findByText("Make Public")).toBeInTheDocument();
137+
});
138+
});
139+
140+
test("action handles archive correctly", async () => {
141+
// @ts-ignore
142+
vi.mocked(authenticator.isAuthenticated).mockResolvedValue({
143+
id: 1,
144+
userName: "testuser",
145+
});
146+
const RemixStub = createRemixStub([
147+
{
148+
path: "/:userName",
149+
Component: UserProfile,
150+
loader,
151+
action,
152+
},
153+
]);
154+
render(<RemixStub initialEntries={["/testuser"]} />);
155+
156+
const menuButtons = await screen.findAllByLabelText("More options");
157+
expect(menuButtons.length).toBeGreaterThan(0);
158+
159+
await userEvent.click(menuButtons[0]);
160+
161+
expect(await screen.findByText("Delete")).toBeInTheDocument();
162+
await userEvent.click(await screen.findByText("Delete"));
163+
expect(
164+
await screen.findByText(
165+
"This action cannot be undone. Are you sure you want to delete this page?",
166+
),
167+
).toBeInTheDocument();
168+
169+
await userEvent.click(await screen.findByText("Delete"));
170+
171+
await waitFor(() => {
172+
expect(screen.queryByText("Test Page")).not.toBeInTheDocument();
173+
});
174+
});
175+
});

web/app/routes/$userName+/index.tsx

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { useNavigate } from "@remix-run/react";
77
import type { MetaFunction } from "@remix-run/react";
88
import Linkify from "linkify-react";
99
import { Lock, MoreVertical, Settings } from "lucide-react";
10+
import { BookOpen, Trash } from "lucide-react";
11+
import { useState } from "react";
1012
import { Button } from "~/components/ui/button";
1113
import {
1214
Card,
@@ -15,6 +17,14 @@ import {
1517
CardHeader,
1618
CardTitle,
1719
} from "~/components/ui/card";
20+
import {
21+
Dialog,
22+
DialogContent,
23+
DialogDescription,
24+
DialogFooter,
25+
DialogHeader,
26+
DialogTitle,
27+
} from "~/components/ui/dialog";
1828
import {
1929
DropdownMenu,
2030
DropdownMenuContent,
@@ -84,10 +94,12 @@ export async function action({ request }: ActionFunctionArgs) {
8494
}
8595
}
8696

87-
export default function UserProfile() {
97+
export default function UserPage() {
8898
const navigate = useNavigate();
8999
const { sanitizedUserWithPages, isOwner, pageCreatedAt } =
90100
useLoaderData<typeof loader>();
101+
const [dialogOpen, setDialogOpen] = useState(false);
102+
const [pageToDelete, setPageToDelete] = useState<number | null>(null);
91103

92104
const fetcher = useFetcher();
93105

@@ -99,15 +111,18 @@ export default function UserProfile() {
99111
};
100112

101113
const handleArchive = (pageId: number) => {
102-
if (
103-
confirm(
104-
"Are you sure you want to delete this page? This action cannot be undone.",
105-
)
106-
) {
107-
fetcher.submit({ intent: "archive", pageId: pageId }, { method: "post" });
114+
setPageToDelete(pageId);
115+
setDialogOpen(true);
116+
};
117+
const confirmArchive = () => {
118+
if (pageToDelete) {
119+
fetcher.submit(
120+
{ intent: "archive", pageId: pageToDelete },
121+
{ method: "post" },
122+
);
108123
}
124+
setDialogOpen(false);
109125
};
110-
111126
return (
112127
<div className="">
113128
<div className="mb-6 rounded-3xl w-full overflow-hidden ">
@@ -166,6 +181,7 @@ export default function UserProfile() {
166181
<Button
167182
variant="ghost"
168183
className="h-8 w-8 p-0 absolute top-2 right-2"
184+
aria-label="More options"
169185
>
170186
<MoreVertical className="h-4 w-4" />
171187
</Button>
@@ -213,6 +229,29 @@ export default function UserProfile() {
213229
{isOwner ? "You haven't created any pages yet." : "No pages yet."}
214230
</p>
215231
)}
232+
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
233+
<DialogContent>
234+
<DialogHeader>
235+
<DialogTitle className="flex items-center">
236+
<Trash className="w-4 h-4 mr-2" />
237+
<BookOpen className="w-4 h-4 mr-2" />
238+
</DialogTitle>
239+
<DialogDescription>
240+
This action cannot be undone. Are you sure you want to delete this
241+
page?
242+
</DialogDescription>
243+
</DialogHeader>
244+
<DialogFooter>
245+
<Button variant="outline" onClick={() => setDialogOpen(false)}>
246+
Cancel
247+
</Button>
248+
<Button variant="destructive" onClick={confirmArchive}>
249+
<Trash className="w-4 h-4 mr-2" />
250+
Delete
251+
</Button>
252+
</DialogFooter>
253+
</DialogContent>
254+
</Dialog>
216255
</div>
217256
);
218257
}

web/package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
"start": "NODE_OPTIONS='--import ./instrumentation.server.mjs' remix-serve ./build/server/index.js",
1010
"typecheck": "tsc --noEmit",
1111
"check": "bunx @biomejs/biome check --write .",
12-
"seed": "prisma db seed"
12+
"seed": "prisma db seed",
13+
"test": "vitest"
1314
},
1415
"prisma": {
1516
"seed": "tsx prisma/seed.ts"
@@ -96,6 +97,7 @@
9697
"@remix-run/dev": "^2.12.0",
9798
"@remix-run/testing": "^2.12.0",
9899
"@testing-library/jest-dom": "^6.5.0",
100+
"@testing-library/react": "^16.0.1",
99101
"@testing-library/user-event": "^14.5.2",
100102
"@types/bcryptjs": "^2.4.6",
101103
"@types/jsdom": "^21.1.7",
@@ -114,7 +116,8 @@
114116
"vite": "^5.4.5",
115117
"vite-env-only": "^3.0.3",
116118
"vite-tsconfig-paths": "^5.0.1",
117-
"vitest": "^2.1.1"
119+
"vitest": "^2.1.1",
120+
"vitest-environment-vprisma": "^1.3.0"
118121
},
119122
"engines": {
120123
"node": ">=20.0.0"

web/tsconfig.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,12 @@
99
],
1010
"compilerOptions": {
1111
"lib": ["DOM", "DOM.Iterable", "ES2022"],
12-
"types": ["@remix-run/node", "vite/client"],
12+
"types": [
13+
"@remix-run/node",
14+
"vite/client",
15+
"vitest/globals",
16+
"vitest-environment-vprisma"
17+
],
1318
"isolatedModules": true,
1419
"esModuleInterop": true,
1520
"jsx": "react-jsx",

web/vite.config.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ export default defineConfig({
2121
},
2222
ignoredRouteFiles: ["**/*"],
2323
routes: async (defineRoutes) => {
24-
return flatRoutes("routes", defineRoutes);
24+
return flatRoutes("routes", defineRoutes, {
25+
ignoredRouteFiles: ["**/*.test.{js,jsx,ts,tsx}"],
26+
});
2527
},
2628
}),
2729
tsconfigPaths(),

web/vitest.config.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,13 @@ import * as VitestConfig from "vitest/config";
44
export default VitestConfig.defineConfig({
55
test: {
66
globals: true,
7-
environment: "jsdom",
7+
environment: "vprisma",
8+
setupFiles: ["vitest-environment-vprisma/setup", "vitest.setup.ts"],
9+
environmentOptions: {
10+
vprisma: {
11+
baseEnv: "jsdom",
12+
},
13+
},
814
},
915
resolve: {
1016
alias: {

web/vitest.setup.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { vi } from "vitest";
2+
3+
vi.mock("~/utils/prisma", () => ({
4+
// @ts-ignore
5+
prisma: vPrisma.client,
6+
}));

0 commit comments

Comments
 (0)