Skip to content

Commit f94fa2a

Browse files
committed
Merge remote-tracking branch 'origin/dev' into add-audio-chants
2 parents bcf06af + 3a99e39 commit f94fa2a

5 files changed

Lines changed: 274 additions & 0 deletions

File tree

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import type { ComponentProps } from "react";
2+
import { render, screen, waitFor } from "@testing-library/react";
3+
import userEvent from "@testing-library/user-event";
4+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
5+
import { beforeEach, describe, expect, it, vi } from "vitest";
6+
import VerseOfDayForm from "./VerseOfDayForm";
7+
import type { VerseOfDayItem } from "./api/verseOfDayApi";
8+
import { createVerseOfDay, updateVerseOfDay } from "./api/verseOfDayApi";
9+
10+
const GROUP_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
11+
12+
vi.mock("sonner", () => ({
13+
toast: { error: vi.fn(), success: vi.fn() },
14+
}));
15+
16+
vi.mock("@/hooks/useLanguages", () => ({
17+
useLanguages: () => ({
18+
languageOptions: [{ value: "EN", label: "English" }],
19+
getLanguageLabel: () => "English",
20+
}),
21+
}));
22+
23+
vi.mock("@/components/routes/groups/api/groupsApi", async () => {
24+
const actual = await vi.importActual<
25+
typeof import("@/components/routes/groups/api/groupsApi")
26+
>("@/components/routes/groups/api/groupsApi");
27+
return {
28+
...actual,
29+
fetchGroups: vi.fn(async () => ({
30+
groups: [
31+
{
32+
id: GROUP_ID,
33+
slug: "dhamma-group",
34+
is_public: true,
35+
metadata: [{ title: "Dhamma group", language: "EN" }],
36+
tags: [],
37+
follower_count: 0,
38+
},
39+
],
40+
skip: 0,
41+
limit: 100,
42+
total: 1,
43+
})),
44+
};
45+
});
46+
47+
vi.mock("./api/verseOfDayApi", async () => {
48+
const actual = await vi.importActual<typeof import("./api/verseOfDayApi")>(
49+
"./api/verseOfDayApi",
50+
);
51+
return {
52+
...actual,
53+
createVerseOfDay: vi.fn(),
54+
updateVerseOfDay: vi.fn(),
55+
};
56+
});
57+
58+
const existingVerse = (
59+
overrides: Partial<VerseOfDayItem> = {},
60+
): VerseOfDayItem => ({
61+
id: "verse-1",
62+
verses: { en: "May all beings be happy." },
63+
verse: "May all beings be happy.",
64+
image_url: null,
65+
ref_id: "text-123",
66+
source: "Dhp 1.5",
67+
ref_type: "sutra",
68+
date: "2025-06-05",
69+
group_id: GROUP_ID,
70+
group_info: [],
71+
...overrides,
72+
});
73+
74+
const renderForm = (
75+
props: Partial<ComponentProps<typeof VerseOfDayForm>> = {},
76+
) => {
77+
const queryClient = new QueryClient({
78+
defaultOptions: { queries: { retry: false } },
79+
});
80+
const onSuccess = vi.fn();
81+
const onCancel = vi.fn();
82+
83+
render(
84+
<QueryClientProvider client={queryClient}>
85+
<VerseOfDayForm
86+
mode="create"
87+
onSuccess={onSuccess}
88+
onCancel={onCancel}
89+
existingVerses={[]}
90+
{...props}
91+
/>
92+
</QueryClientProvider>,
93+
);
94+
95+
return { onSuccess, onCancel };
96+
};
97+
98+
const fillRequiredCreateFields = async () => {
99+
await userEvent.type(
100+
screen.getByPlaceholderText(/enter verse content in english/i),
101+
"May all beings be happy.",
102+
);
103+
await userEvent.click(
104+
await screen.findByRole("combobox", { name: /select a group/i }),
105+
);
106+
await userEvent.click(await screen.findByText("Dhamma group"));
107+
};
108+
109+
describe("VerseOfDayForm — source", () => {
110+
beforeEach(() => {
111+
vi.mocked(createVerseOfDay).mockReset();
112+
vi.mocked(updateVerseOfDay).mockReset();
113+
vi.mocked(createVerseOfDay).mockResolvedValue({} as never);
114+
vi.mocked(updateVerseOfDay).mockResolvedValue({} as never);
115+
});
116+
117+
it("includes a trimmed source on create", async () => {
118+
renderForm();
119+
await fillRequiredCreateFields();
120+
await userEvent.type(
121+
screen.getByLabelText(/source \/ reference/i),
122+
" Dhp 1.5 ",
123+
);
124+
125+
await userEvent.click(screen.getByRole("button", { name: /^create$/i }));
126+
127+
await waitFor(() => expect(createVerseOfDay).toHaveBeenCalledTimes(1));
128+
expect(createVerseOfDay).toHaveBeenCalledWith(
129+
expect.objectContaining({ source: "Dhp 1.5" }),
130+
);
131+
});
132+
133+
it("sends null source on create when the field is empty", async () => {
134+
renderForm();
135+
await fillRequiredCreateFields();
136+
137+
await userEvent.click(screen.getByRole("button", { name: /^create$/i }));
138+
139+
await waitFor(() => expect(createVerseOfDay).toHaveBeenCalledTimes(1));
140+
expect(createVerseOfDay).toHaveBeenCalledWith(
141+
expect.objectContaining({ source: null }),
142+
);
143+
});
144+
145+
it("pre-fills source in edit mode and omits it when unchanged", async () => {
146+
renderForm({
147+
mode: "edit",
148+
initialData: existingVerse(),
149+
existingVerses: [existingVerse()],
150+
});
151+
152+
expect(screen.getByLabelText(/source \/ reference/i)).toHaveValue(
153+
"Dhp 1.5",
154+
);
155+
156+
await userEvent.click(screen.getByRole("button", { name: /^update$/i }));
157+
158+
await waitFor(() => expect(updateVerseOfDay).toHaveBeenCalledTimes(1));
159+
const [, payload] = vi.mocked(updateVerseOfDay).mock.calls[0];
160+
expect(payload).not.toHaveProperty("source");
161+
});
162+
163+
it("sends the trimmed source on update when it changes", async () => {
164+
renderForm({
165+
mode: "edit",
166+
initialData: existingVerse(),
167+
existingVerses: [existingVerse()],
168+
});
169+
170+
const sourceInput = screen.getByLabelText(/source \/ reference/i);
171+
await userEvent.clear(sourceInput);
172+
await userEvent.type(sourceInput, " Dhp 2.1 ");
173+
await userEvent.click(screen.getByRole("button", { name: /^update$/i }));
174+
175+
await waitFor(() => expect(updateVerseOfDay).toHaveBeenCalledTimes(1));
176+
expect(updateVerseOfDay).toHaveBeenCalledWith(
177+
"verse-1",
178+
expect.objectContaining({ source: "Dhp 2.1" }),
179+
);
180+
});
181+
182+
it("sends null source on update when the field is cleared", async () => {
183+
renderForm({
184+
mode: "edit",
185+
initialData: existingVerse(),
186+
existingVerses: [existingVerse()],
187+
});
188+
189+
await userEvent.clear(screen.getByLabelText(/source \/ reference/i));
190+
await userEvent.click(screen.getByRole("button", { name: /^update$/i }));
191+
192+
await waitFor(() => expect(updateVerseOfDay).toHaveBeenCalledTimes(1));
193+
expect(updateVerseOfDay).toHaveBeenCalledWith(
194+
"verse-1",
195+
expect.objectContaining({ source: null }),
196+
);
197+
});
198+
});

‎src/components/routes/verse-of-day/VerseOfDayForm.tsx‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
33
import { toast } from "sonner";
44
import { Pecha } from "@/components/ui/shadimport";
55
import { Textarea } from "@/components/ui/atoms/textarea";
6+
import { Input } from "@/components/ui/atoms/input";
67
import { Button } from "@/components/ui/atoms/button";
78
import { Calendar } from "@/components/ui/atoms/calendar";
89
import { useLanguages } from "@/hooks/useLanguages";
@@ -54,6 +55,7 @@ const VerseOfDayForm = ({
5455
const [isImageDialogOpen, setIsImageDialogOpen] = useState(false);
5556
const [isImageUploading, setIsImageUploading] = useState(false);
5657
const [groupId, setGroupId] = useState("");
58+
const [source, setSource] = useState("");
5759
const [date, setDate] = useState<Date | undefined>(new Date());
5860
const [showCalendar, setShowCalendar] = useState(false);
5961

@@ -73,13 +75,15 @@ const VerseOfDayForm = ({
7375
setImagePreview(initialData.image_url || null);
7476
// Use group_id directly from the response
7577
setGroupId(initialData.group_id || "");
78+
setSource(initialData.source || "");
7679
setDate(parse(initialData.date, "yyyy-MM-dd", new Date()));
7780
} else {
7881
setActiveLanguage("EN");
7982
setVerses(emptyVerses(languageCodes.length > 0 ? languageCodes : ["EN"]));
8083
setImageKey(null);
8184
setImagePreview(null);
8285
setGroupId("");
86+
setSource("");
8387
setDate(new Date());
8488
}
8589
}, [mode, initialData, languageCodes.join(",")]);
@@ -171,6 +175,7 @@ const VerseOfDayForm = ({
171175
const trimmedVerses = Object.fromEntries(
172176
Object.entries(verses).map(([key, value]) => [key, value.trim()]),
173177
);
178+
const trimmedSource = source.trim() || null;
174179

175180
// For create, send all required fields
176181
// For update, only send fields that have changed
@@ -201,13 +206,19 @@ const VerseOfDayForm = ({
201206
updatePayload.group_id = trimmedGroupId;
202207
}
203208

209+
const previousSource = initialData.source?.trim() || null;
210+
if (trimmedSource !== previousSource) {
211+
updatePayload.source = trimmedSource;
212+
}
213+
204214
updateMutation.mutate({ id: initialData.id, payload: updatePayload });
205215
} else {
206216
const createPayload: any = {
207217
verses: trimmedVerses,
208218
image_urls: imageKey ? [imageKey] : [],
209219
group_id: groupId.trim() || null,
210220
date: format(date, "yyyy-MM-dd"),
221+
source: trimmedSource,
211222
};
212223
createMutation.mutate(createPayload);
213224
}
@@ -251,6 +262,18 @@ const VerseOfDayForm = ({
251262
/>
252263
</div>
253264

265+
<div className="space-y-2">
266+
<label htmlFor="verse-source" className="text-sm font-bold">
267+
Source / Reference
268+
</label>
269+
<Input
270+
id="verse-source"
271+
value={source}
272+
onChange={(e) => setSource(e.target.value)}
273+
placeholder="Dhp 1.5"
274+
/>
275+
</div>
276+
254277
<div className="space-y-2">
255278
<label className="text-sm font-bold">Image</label>
256279
<div className="flex gap-4 items-start">
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { render, screen } from "@testing-library/react";
2+
import { describe, expect, it, vi } from "vitest";
3+
import VerseOfDayList from "./VerseOfDayList";
4+
import type { VerseOfDayItem } from "./api/verseOfDayApi";
5+
6+
const verse = (overrides: Partial<VerseOfDayItem> = {}): VerseOfDayItem => ({
7+
id: "verse-1",
8+
verses: { en: "May all beings be happy." },
9+
verse: "May all beings be happy.",
10+
image_url: null,
11+
ref_id: "text-123",
12+
source: null,
13+
ref_type: "sutra",
14+
date: "2025-06-05",
15+
group_id: null,
16+
group_info: [],
17+
...overrides,
18+
});
19+
20+
const renderList = (verses: VerseOfDayItem[]) =>
21+
render(
22+
<VerseOfDayList
23+
verses={verses}
24+
sortOrder="desc"
25+
onToggleSort={vi.fn()}
26+
onEdit={vi.fn()}
27+
onDelete={vi.fn()}
28+
/>,
29+
);
30+
31+
describe("VerseOfDayList — source", () => {
32+
it("renders the source under the verse text when present", () => {
33+
renderList([verse({ source: "Dhp 1.5" })]);
34+
35+
expect(screen.getByText("May all beings be happy.")).toBeInTheDocument();
36+
expect(screen.getByText("Dhp 1.5")).toBeInTheDocument();
37+
});
38+
39+
it("does not render a source line when the response omits it", () => {
40+
renderList([verse({ source: null })]);
41+
42+
expect(screen.getByText("May all beings be happy.")).toBeInTheDocument();
43+
expect(screen.queryByText("Dhp 1.5")).not.toBeInTheDocument();
44+
});
45+
});

‎src/components/routes/verse-of-day/VerseOfDayList.tsx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,11 @@ const VerseOfDayList = ({
7979
<span className="text-muted-foreground">—</span>
8080
)}
8181
</p>
82+
{verse.source ? (
83+
<p className="mt-1 text-xs text-muted-foreground line-clamp-1">
84+
{verse.source}
85+
</p>
86+
) : null}
8287
</Pecha.TableCell>
8388
<Pecha.TableCell>
8489
{verse.image_url ? (

‎src/components/routes/verse-of-day/api/verseOfDayApi.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export interface VerseOfDayPayload {
99
image_urls: string[];
1010
verse_id: string;
1111
ref_id: string;
12+
source?: string | null;
1213
ref_type: string;
1314
group_id: string;
1415
date: string;
@@ -28,6 +29,7 @@ export interface VerseOfDayItem {
2829
verse: string | null;
2930
image_url: string | null;
3031
ref_id: string;
32+
source?: string | null;
3133
ref_type: string;
3234
date: string;
3335
group_id: string | null;
@@ -45,6 +47,7 @@ export interface VerseOfDayResponse {
4547
image_urls: string[];
4648
verse_id: string;
4749
ref_id: string;
50+
source?: string | null;
4851
ref_type: string;
4952
group_id: string;
5053
date: string;

0 commit comments

Comments
 (0)