From 6606d2be756241958f53daefca174aa89862d914 Mon Sep 17 00:00:00 2001 From: Tech-lo Date: Mon, 29 Jun 2026 12:34:03 +0530 Subject: [PATCH 1/6] add videos to plan --- .../routes/create-plan/CreatePlan.tsx | 12 + .../routes/create-plan/PlanVideosSection.tsx | 265 ++++++++++++++++++ src/components/routes/task/api/planApi.ts | 59 ++++ 3 files changed, 336 insertions(+) create mode 100644 src/components/routes/create-plan/PlanVideosSection.tsx diff --git a/src/components/routes/create-plan/CreatePlan.tsx b/src/components/routes/create-plan/CreatePlan.tsx index d938680..9f57b51 100644 --- a/src/components/routes/create-plan/CreatePlan.tsx +++ b/src/components/routes/create-plan/CreatePlan.tsx @@ -21,6 +21,8 @@ import { planSchema } from "@/schema/PlanSchema"; import { z } from "zod"; import { useTranslate } from "@tolgee/react"; import PlanTagSearchInput from "./PlanTagSearchInput"; +import PlanVideosSection from "./PlanVideosSection"; +import type { PlanVideoSummary } from "@/components/routes/task/api/planApi"; import { planTagsToIds, type PlanTagSummary, @@ -259,6 +261,11 @@ const Createplan = () => { } }, [planId, planData]); + const planVideos = useMemo( + () => (Array.isArray(planData?.videos) ? planData.videos : []), + [planData], + ); + const canUpdate = form.formState.isDirty; const hasUnsavedChanges = canUpdate && !form.formState.isSubmitSuccessful; @@ -933,6 +940,11 @@ const Createplan = () => { )} /> + + {planId && ( + + )} +
{isCreateMode ? ( + Boolean(getYouTubeVideoId(url) || getYouTubeShortsId(url)); + +const sortByOrder = (videos: PlanVideoSummary[]) => + [...videos].sort((a, b) => a.display_order - b.display_order); + +const PlanVideosSection = ({ planId, videos = [] }: PlanVideosSectionProps) => { + const [url, setUrl] = useState(""); + const [title, setTitle] = useState(""); + // Local copy so drag-reorder updates instantly before the server confirms. + const [orderedVideos, setOrderedVideos] = useState( + sortByOrder(videos), + ); + const queryClient = useQueryClient(); + + useEffect(() => { + setOrderedVideos(sortByOrder(videos)); + }, [videos]); + + const invalidatePlan = () => + queryClient.invalidateQueries({ queryKey: ["plan", planId] }); + + const addMutation = useMutation({ + mutationFn: (payload: { url: string; title?: string | null }) => + addPlanVideo(planId, payload), + onSuccess: (video) => { + setUrl(""); + setTitle(""); + setOrderedVideos((prev) => sortByOrder([...prev, video])); + toast.success("Video added"); + invalidatePlan(); + }, + onError: (error: unknown) => { + toast.error("Failed to add video", { + description: getApiErrorMessage(error), + }); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: (videoId: string) => deletePlanVideo(planId, videoId), + onSuccess: (_data, videoId) => { + setOrderedVideos((prev) => prev.filter((v) => v.id !== videoId)); + toast.success("Video removed"); + invalidatePlan(); + }, + onError: (error: unknown) => { + toast.error("Failed to remove video", { + description: getApiErrorMessage(error), + }); + }, + }); + + const reorderMutation = useMutation({ + mutationFn: (payload: Array<{ id: string; display_order: number }>) => + reorderPlanVideos(planId, payload), + onSuccess: (updated) => { + setOrderedVideos(sortByOrder(updated)); + invalidatePlan(); + }, + onError: (error: unknown) => { + setOrderedVideos(sortByOrder(videos)); // revert optimistic order + toast.error("Failed to reorder videos", { + description: getApiErrorMessage(error), + }); + }, + }); + + const trimmedUrl = url.trim(); + const urlIsValid = isYouTubeUrl(trimmedUrl); + const isBusy = + addMutation.isPending || + deleteMutation.isPending || + reorderMutation.isPending; + + const handleAdd = () => { + if (!trimmedUrl || !urlIsValid) return; + const trimmedTitle = title.trim(); + addMutation.mutate({ + url: trimmedUrl, + ...(trimmedTitle ? { title: trimmedTitle } : {}), + }); + }; + + const handleReorder = (activeId: string, overId: string) => { + if (activeId === overId) return; + const next = reorderArray(orderedVideos, activeId, overId); + if (!next) return; + setOrderedVideos(next); + reorderMutation.mutate( + next.map((video, index) => ({ id: video.id, display_order: index })), + ); + }; + + return ( +
+
+ +

YouTube videos

+ {orderedVideos.length > 0 && ( + + {orderedVideos.length} + + )} +
+ +
+
+ setUrl(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleAdd(); + } + }} + /> + setTitle(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleAdd(); + } + }} + /> + + {addMutation.isPending ? ( + + ) : ( + + )} + Add + +
+ {trimmedUrl && !urlIsValid && ( +

+ Please enter a valid YouTube URL. +

+ )} +
+ +
+ {orderedVideos.length === 0 ? ( +

+ No videos added yet. +

+ ) : ( + ({ id: video.id }))} + onReorder={handleReorder} + disabled={isBusy} + > +
+ {orderedVideos.map((video) => { + const videoId = + video.video_id || + getYouTubeVideoId(video.url) || + getYouTubeShortsId(video.url); + return ( + + {({ listeners }: any) => ( + <> + + {videoId && ( + {video.title + )} +
+ + {video.title || video.url} + + + {video.url} + +
+ deleteMutation.mutate(video.id)} + > + {deleteMutation.isPending && + deleteMutation.variables === video.id ? ( + + ) : ( + + )} + + + )} +
+ ); + })} +
+
+ )} +
+
+ ); +}; + +export default PlanVideosSection; diff --git a/src/components/routes/task/api/planApi.ts b/src/components/routes/task/api/planApi.ts index c36f61a..85a9c55 100644 --- a/src/components/routes/task/api/planApi.ts +++ b/src/components/routes/task/api/planApi.ts @@ -289,3 +289,62 @@ export const reorderDayVideos = async ( ); return data.videos; }; + +export interface PlanVideoDTO { + id: string; + plan_id: string; + url: string; + video_id: string | null; + title: string | null; + display_order: number; + created_at: string | null; +} + +export type PlanVideoSummary = Pick< + PlanVideoDTO, + "id" | "url" | "video_id" | "title" | "display_order" +>; + +export interface AddPlanVideoPayload { + url: string; + title?: string | null; +} + +export const fetchPlanVideos = async (plan_id: string) => { + const { data } = await axiosInstance.get<{ videos: PlanVideoDTO[] }>( + `/api/v1/cms/plans/${plan_id}/videos`, + { headers: getAuthHeaders() }, + ); + return data.videos; +}; + +export const addPlanVideo = async ( + plan_id: string, + payload: AddPlanVideoPayload, +) => { + const { data } = await axiosInstance.post( + `/api/v1/cms/plans/${plan_id}/videos`, + payload, + { headers: getAuthHeaders() }, + ); + return data; +}; + +export const deletePlanVideo = async (plan_id: string, video_id: string) => { + await axiosInstance.delete( + `/api/v1/cms/plans/${plan_id}/videos/${video_id}`, + { headers: getAuthHeaders() }, + ); +}; + +export const reorderPlanVideos = async ( + plan_id: string, + videos: Array<{ id: string; display_order: number }>, +) => { + const { data } = await axiosInstance.put<{ videos: PlanVideoDTO[] }>( + `/api/v1/cms/plans/${plan_id}/videos/order`, + { videos }, + { headers: getAuthHeaders() }, + ); + return data.videos; +}; From d56eff3676a89c0fc4098e19b6aac2f6753ce9f6 Mon Sep 17 00:00:00 2001 From: Tech-lo Date: Mon, 29 Jun 2026 16:35:08 +0530 Subject: [PATCH 2/6] update failing tests --- .../routes/create-plan/CreatePlan.test.tsx | 34 ++++++++++++++++++- .../routes/profile/Profile.test.tsx | 9 +++++ .../routes/task/PlanDetailsPage.test.tsx | 19 +++++++---- .../task/components/view/TaskForm.test.tsx | 2 +- src/test/setup.ts | 21 ++++++++---- 5 files changed, 70 insertions(+), 15 deletions(-) diff --git a/src/components/routes/create-plan/CreatePlan.test.tsx b/src/components/routes/create-plan/CreatePlan.test.tsx index 5d4807a..5216e21 100644 --- a/src/components/routes/create-plan/CreatePlan.test.tsx +++ b/src/components/routes/create-plan/CreatePlan.test.tsx @@ -81,6 +81,16 @@ vi.mock( describe("CreatePlan Component", () => { beforeEach(() => { vi.clearAllMocks(); + // `clearAllMocks` resets call history but keeps implementations set via + // `mockReturnValue`. The navigation tests force `useBlocker` into a + // "blocked" state, which opens the navigation dialog; reset it here so + // that leaked dialog doesn't `aria-hidden` the form in later tests. + vi.mocked(useBlocker).mockReturnValue({ + state: "unblocked", + proceed: vi.fn(), + reset: vi.fn(), + location: undefined as never, + }); vi.mocked(useParams).mockReturnValue({ groupId: "test-group-id" }); vi.mocked(useLocation).mockReturnValue({ pathname: "/groups/test-group-id/plan/new", @@ -428,13 +438,35 @@ describe("CreatePlan Component", () => { screen.getByRole("button", { name: 'Create "Brand New Tag"' }), ); + // Clicking the create option opens a multi-language dialog with the + // typed name pre-filled for the default (EN) language. + const createTagButton = await screen.findByRole("button", { + name: "Create Tag", + }); + fireEvent.click(createTagButton); + await waitFor(() => { expect(axiosInstance.post).toHaveBeenCalledWith( "/api/v1/cms/tags", - expect.objectContaining({ name: "Brand New Tag" }), + expect.objectContaining({ + metadata: expect.arrayContaining([ + expect.objectContaining({ + language: "EN", + name: "Brand New Tag", + }), + ]), + }), expect.any(Object), ); }); + + // Successful creation closes the dialog; wait for it so the Radix + // scroll-lock is torn down and does not leak into later tests. + await waitFor(() => { + expect( + screen.queryByRole("button", { name: "Create Tag" }), + ).not.toBeInTheDocument(); + }); }); it("shows no image preview initially", () => { diff --git a/src/components/routes/profile/Profile.test.tsx b/src/components/routes/profile/Profile.test.tsx index 1fe1807..7c6018e 100644 --- a/src/components/routes/profile/Profile.test.tsx +++ b/src/components/routes/profile/Profile.test.tsx @@ -19,6 +19,15 @@ const mockUserInfo = { ], }; +vi.mock("@/hooks/useUserInfo", () => ({ + USER_INFO_QUERY_KEY: ["userInfo"], + fetchUserInfo: vi.fn(), + useUserInfo: () => ({ + data: mockUserInfo, + isLoading: false, + }), +})); + vi.mock("@/components/ui/molecules/user-card/UserCard", () => ({ default: ({ userInfo }: any) => (
diff --git a/src/components/routes/task/PlanDetailsPage.test.tsx b/src/components/routes/task/PlanDetailsPage.test.tsx index a6b9a55..dd089ca 100644 --- a/src/components/routes/task/PlanDetailsPage.test.tsx +++ b/src/components/routes/task/PlanDetailsPage.test.tsx @@ -190,11 +190,14 @@ describe("PlanDetailsPanel Component", () => { await waitFor(() => { expect(screen.getByText("Day 4")).toBeInTheDocument(); }); + // "Add New Day" now opens a dialog; submitting it triggers the request. fireEvent.click(screen.getByText("Add New Day")); + const submitButton = await screen.findByText("Add 1 Day"); + fireEvent.click(submitButton); await waitFor(() => { expect(mockAxios.post).toHaveBeenCalledWith( expect.stringContaining("/api/v1/cms/plans/test-plan-id/days"), - {}, + expect.objectContaining({ number_of_days: 1 }), expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer mock-token", @@ -217,8 +220,10 @@ describe("PlanDetailsPanel Component", () => { expect(screen.getByText("Day 4")).toBeInTheDocument(); }); fireEvent.click(screen.getByText("Add New Day")); + const submitButton = await screen.findByText("Add 1 Day"); + fireEvent.click(submitButton); await waitFor(() => { - expect(toast.error).toHaveBeenCalledWith("Failed to create new day", { + expect(toast.error).toHaveBeenCalledWith("Failed to create days", { description: "Cannot create day", }); }); @@ -229,7 +234,7 @@ describe("PlanDetailsPanel Component", () => { await waitFor(() => { expect(screen.getByText(mockPlanData.title)).toBeInTheDocument(); }); - expect(screen.getByText("Add Task")).toBeInTheDocument(); + expect(screen.getAllByText("Add Task").length).toBeGreaterThan(0); expect(screen.getByPlaceholderText("Task Title")).toBeInTheDocument(); }); @@ -254,10 +259,10 @@ describe("PlanDetailsPanel Component", () => { await waitFor(() => { expect(screen.getByText(mockPlanData.title)).toBeInTheDocument(); }); - expect(screen.getByText("Add Task")).toBeInTheDocument(); + expect(screen.getAllByText("Add Task").length).toBeGreaterThan(0); fireEvent.click(screen.getByText("Morning Intention Setting")); await waitFor(() => { - expect(screen.queryByText("Add Task")).not.toBeInTheDocument(); + expect(screen.queryAllByText("Add Task")).toHaveLength(0); }); await waitFor(() => { expect(screen.getByText("Task")).toBeInTheDocument(); @@ -341,7 +346,7 @@ describe("PlanDetailsPanel Component", () => { await user.click(deleteTaskBtn); await waitFor(() => { - expect(screen.getByText("Add Task")).toBeInTheDocument(); + expect(screen.getAllByText("Add Task").length).toBeGreaterThan(0); }); }); @@ -398,7 +403,7 @@ describe("PlanDetailsPanel Component", () => { await user.click(deleteTaskBtn); await waitFor(() => { - expect(screen.getByText("Add Task")).toBeInTheDocument(); + expect(screen.getAllByText("Add Task").length).toBeGreaterThan(0); }); }); }); diff --git a/src/components/routes/task/components/view/TaskForm.test.tsx b/src/components/routes/task/components/view/TaskForm.test.tsx index 3409043..0eb6cdd 100644 --- a/src/components/routes/task/components/view/TaskForm.test.tsx +++ b/src/components/routes/task/components/view/TaskForm.test.tsx @@ -223,7 +223,7 @@ describe("TaskForm Component", () => { it("renders task form with title input", async () => { renderWithProviders(); - expect(screen.getByText("Add Task")).toBeInTheDocument(); + expect(screen.getAllByText("Add Task").length).toBeGreaterThan(0); expect(screen.getByPlaceholderText("Task Title")).toBeInTheDocument(); }); diff --git a/src/test/setup.ts b/src/test/setup.ts index 02d5d9d..6470342 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -1,5 +1,10 @@ import "@testing-library/jest-dom"; -import { vi } from "vitest"; +import { afterEach, vi } from "vitest"; + +afterEach(() => { + document.body.removeAttribute("data-scroll-locked"); + document.body.style.pointerEvents = ""; +}); vi.mock("@/config/auth-context", () => ({ useAuth: () => ({ @@ -23,6 +28,10 @@ vi.mock("@tolgee/react", () => ({ useTranslate: () => ({ t: (key: string) => key, }), + useTolgee: () => ({ + getLanguage: () => "en", + changeLanguage: vi.fn(), + }), })); vi.mock("@/hooks/useUserInfo", () => ({ @@ -50,8 +59,8 @@ Object.defineProperty(global, "URL", { writable: true, }); -global.ResizeObserver = vi.fn().mockImplementation(() => ({ - observe: vi.fn(), - unobserve: vi.fn(), - disconnect: vi.fn(), -})); +global.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +}; From 95cb50cfe556f7ed3cd1df8e03de7befa321c237 Mon Sep 17 00:00:00 2001 From: Tech-lo Date: Mon, 29 Jun 2026 18:12:40 +0530 Subject: [PATCH 3/6] build fix --- src/components/routes/create-plan/CreatePlan.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/routes/create-plan/CreatePlan.test.tsx b/src/components/routes/create-plan/CreatePlan.test.tsx index 5216e21..83cf990 100644 --- a/src/components/routes/create-plan/CreatePlan.test.tsx +++ b/src/components/routes/create-plan/CreatePlan.test.tsx @@ -87,9 +87,9 @@ describe("CreatePlan Component", () => { // that leaked dialog doesn't `aria-hidden` the form in later tests. vi.mocked(useBlocker).mockReturnValue({ state: "unblocked", - proceed: vi.fn(), - reset: vi.fn(), - location: undefined as never, + proceed: undefined, + reset: undefined, + location: undefined, }); vi.mocked(useParams).mockReturnValue({ groupId: "test-group-id" }); vi.mocked(useLocation).mockReturnValue({ From ce5491a19ac4490d60999a6ede8de02453576d94 Mon Sep 17 00:00:00 2001 From: Tech-lo Date: Mon, 29 Jun 2026 18:14:21 +0530 Subject: [PATCH 4/6] lint fix --- .../create-plan/PlanNewLegacyRedirect.tsx | 5 +- .../routes/create-plan/PlanTagSearchInput.tsx | 52 ++++-- .../routes/create-series/api/seriesApi.ts | 5 +- .../routes/dashboard/dashboardTable.ts | 4 +- .../routes/dashboard/dashboardUrlState.ts | 5 +- .../CloneLanguagePlansPanel.tsx | 12 +- src/components/routes/tags/TagFormDialog.tsx | 63 +++++-- src/components/routes/task/api/planApi.ts | 17 +- .../components/sidebar-component/SideBar.tsx | 4 +- .../components/view/NotificationForm.test.tsx | 21 ++- .../task/components/view/NotificationForm.tsx | 6 +- .../routes/task/components/view/TaskForm.tsx | 167 +++++++++--------- .../routes/verse-of-day/VerseOfDay.tsx | 9 +- .../routes/verse-of-day/VerseOfDayForm.tsx | 25 ++- .../routes/verse-of-day/VerseOfDayList.tsx | 24 +-- .../routes/verse-of-day/api/verseOfDayApi.ts | 19 +- .../DayShareableImageUpload.tsx | 3 +- src/schema/SeriesSchema.ts | 9 +- 18 files changed, 276 insertions(+), 174 deletions(-) diff --git a/src/components/routes/create-plan/PlanNewLegacyRedirect.tsx b/src/components/routes/create-plan/PlanNewLegacyRedirect.tsx index 2ce870f..ba44c90 100644 --- a/src/components/routes/create-plan/PlanNewLegacyRedirect.tsx +++ b/src/components/routes/create-plan/PlanNewLegacyRedirect.tsx @@ -1,6 +1,9 @@ import { useEffect } from "react"; import { useLocation, useNavigate } from "react-router-dom"; -import { getSeries, resolveSeriesGroupId } from "@/components/routes/create-series/api/seriesApi"; +import { + getSeries, + resolveSeriesGroupId, +} from "@/components/routes/create-series/api/seriesApi"; import { parsePlanNewFromSeriesState } from "./planNewFromSeriesState"; import { ROUTES } from "@/routes/paths"; diff --git a/src/components/routes/create-plan/PlanTagSearchInput.tsx b/src/components/routes/create-plan/PlanTagSearchInput.tsx index 51bb218..20981cd 100644 --- a/src/components/routes/create-plan/PlanTagSearchInput.tsx +++ b/src/components/routes/create-plan/PlanTagSearchInput.tsx @@ -51,8 +51,12 @@ const PlanTagSearchInput = ({ Object.fromEntries(initialTags.map((tag) => [tag.id, tag.name])), ); const [showCreateDialog, setShowCreateDialog] = useState(false); - const [activeLanguages, setActiveLanguages] = useState(["EN"]); - const [languageData, setLanguageData] = useState>({ + const [activeLanguages, setActiveLanguages] = useState([ + "EN", + ]); + const [languageData, setLanguageData] = useState< + Record + >({ EN: { name: "", description: "" }, BO: { name: "", description: "" }, ZH: { name: "", description: "" }, @@ -155,7 +159,9 @@ const PlanTagSearchInput = ({ for (const lang of activeLanguages) { const data = languageData[lang]; if (!data.name.trim()) { - toast.error(`Name is required for ${PLAN_LANGUAGE.find(l => l.value === lang)?.label}`); + toast.error( + `Name is required for ${PLAN_LANGUAGE.find((l) => l.value === lang)?.label}`, + ); return; } metadata.push({ @@ -182,7 +188,7 @@ const PlanTagSearchInput = ({ const updateLanguageField = ( lang: LanguageCode, field: keyof LanguageData, - value: string + value: string, ) => { setLanguageData((prev) => ({ ...prev, @@ -331,7 +337,10 @@ const PlanTagSearchInput = ({
- {PLAN_LANGUAGE.filter((lang) => !activeLanguages.includes(lang.value as LanguageCode)).length > 0 && ( + {PLAN_LANGUAGE.filter( + (lang) => + !activeLanguages.includes(lang.value as LanguageCode), + ).length > 0 && (
{activeLanguages.map((lang) => { - const langLabel = PLAN_LANGUAGE.find((l) => l.value === lang)?.label || lang; + const langLabel = + PLAN_LANGUAGE.find((l) => l.value === lang)?.label || lang; return ( -
+
{activeLanguages.length > 1 && ( )} -
{langLabel}
+
+ {langLabel} +
updateLanguageField(lang, "name", e.target.value)} + onChange={(e) => + updateLanguageField(lang, "name", e.target.value) + } placeholder="Tag name" required className="bg-white dark:bg-[#181818]" @@ -386,7 +408,13 @@ const PlanTagSearchInput = ({