From f14af767769049cda035960b4035702907ae7a42 Mon Sep 17 00:00:00 2001 From: Harshal Madgulkar Date: Mon, 25 May 2026 12:13:48 +0530 Subject: [PATCH 1/8] Series create, edit, and details (#185) * shared component * ui * tag removal * Enhance Dashboard with Series Management and UI Improvements - Updated Dashboard component to include a new tab for managing series alongside plans. - Implemented fetching and displaying series data with a new SeriesTable component. - Refactored add button to a dropdown menu for adding plans and series. - Improved test cases to reflect changes in button labels and API endpoints. - Enhanced PlanDeleteDialog to support dynamic entity labels for better reusability. * integration of plan search, UI not the finalized * initial commit * update the filter function for type safety * Add Dashboard Frontend Plan and Enhance Dashboard Component - Introduced a comprehensive plan document for the CMS dashboard, detailing goals, backend assumptions, and implementation strategies. - Refactored Dashboard component to support a unified table for plans and series, including improved filtering and sorting capabilities. - Implemented a new DashboardContentTable for rendering dashboard data with enhanced UI elements. - Updated tests to reflect changes in component structure and functionality, ensuring accurate rendering and interaction. - Added mock data handling for the unified feed to support frontend development without backend dependencies. * feat(dashboard): add dropdown to create series or plan - replace the direct Add Plan link with an Add menu for series and plan flows - restyle the trigger and use dropdown primitives for clearer navigation - apply prettier formatting and EOF fixes in related create and form UI files * refactor(create-series): stub series route and tweak dashboard sort table - replace the full CreateSeries form with a minimal placeholder until the API is ready - set dashboard sort to recent when the sort select content is clicked - stop destructuring unused isLoading in the dashboard content table * feat(create-series): implement series create and edit with CMS integration - add multi-language series editor with cover upload, plan tabs, and unsaved-change navigation blocking - add series API module for load, create, update, and full plan snapshot replace with graceful partial failure handling - rework plan search to store rich plan objects, filter by language, and improve browse and selection UX - require complete language blocks and a cover image before submit; refine zod validation for languages and image - remove duplicate series route registration and align dashboard series rows with the unified table type * feat(create-series): support plan display order and drag reorder - sort hydrated plans per language using optional display_order from the series detail payload - document that plan id array order in the replace payload represents display order for the API - add drag handles to reorder selected plans before save, disabled when only one plan is attached - remove the desktop-only vertical divider on the series details column for a cleaner split layout * refactor(create-series): unify series create and update payload with plans - send name, featured, image_key, and per-language plan ids in one POST or PUT body - drop the separate plans replace endpoint and client-side author resolution on save - build plan lists only for languages present in the series name payload - remove author profile gating from submit enabled state - show cancel on create as well as edit * style(create-series): polish series editor and plan picker layout - align page background, title divider, and language block surfaces with the design spec - show English, Tibetan, and Chinese labels on language fields and style the add-language control - resize cover upload placeholder and refine description field backgrounds - highlight active plan tabs with brand color and muted plan counts - simplify plan picker chrome and add spacing above the search field * style(create-series): simplify PlanSearchSelector layout - remove unnecessary padding from the PlanSearchSelector component * feat(dashboard): load tabs from unified CMS dashboard items API - add dashboard items client with tab, pagination, search, and filter params mapped to table rows - replace separate plan and series fetches and mock all feed with a single React Query load per view - send language and status filters to the server instead of filtering client-side - use one content table and pagination state for all, plans, and series tabs - update dashboard tests and invalidate dashboard-items after plan and series mutations * feat(dashboard): show plan count badge on series cover thumbnails - overlay a stack badge with plans_count on series rows in the content table - map plans_count from dashboard items API responses into table row data - rename modified column header to Date Modified - add dashboard test for the accessible series plans badge - fix CreatePlan cover image tests to match constraints copy key * feat(dashboard): unify plan and series row actions via DropdownButton - add entityType to DropdownButton for plan vs series API, labels, and edit links - use shared dropdown for series rows with delete and status mutations - remove placeholder onDeleteSeries handler and inline series action menu * feat(dashboard): enable series featured toggle and align table controls - route featured PATCH by row kind (plan vs series) - allow published series rows to toggle featured from the table - refresh featured star states and shared icon button styles for Featured and Actions - add test covering series featured toggle * refactor(dashboard): rely on axios interceptor for auth headers - remove per-request Authorization headers from dashboard fetch, featured toggle, and row actions - simplify featured toggle tests to match patch call signature - tighten status update error handling in DropdownButton * feat(dashboard): support series metadata API and featured updates - resolve dashboard series titles from metadata with language preference - toggle series featured via CMS PUT with featured flag instead of patch endpoint - migrate series write payloads from name map to metadata array - improve series detail mapping for metadata, image_key, and plan language fallbacks - normalize dashboard status to uppercase and link series rows to series-details - invalidate series cache after row status changes - fix Tolgee translate prop typing on dashboard table - add dashboard API tests and expand dashboard component coverage * fix(dashboard): hide cover column header and align tests - remove cover image label from dashboard table header - assert table headers only when rows are present - validate empty cover column and six column headers in tests * feat(series-details): add series details page with plan management - implement series details view with language tabs and plans table - add plan search, reorder, remove, and featured actions with CMS persistence - extend series API and dropdown for plan updates and extra menu items - extract dashboard status and language chips into shared UI helpers - wire series details route and add mapper and page tests --------- Co-authored-by: Tech-lo --- .../routes/create-plan/CreatePlan.test.tsx | 6 +- .../routes/create-plan/CreatePlan.tsx | 33 +- .../routes/create-series/CreateSeries.tsx | 551 ++++++++++++++++++ .../routes/create-series/api/planSearchApi.ts | 50 ++ .../routes/create-series/api/seriesApi.ts | 288 +++++++++ .../components/PlanSearchSelector.tsx | 239 ++++++++ .../create-series/hooks/useSeriesForm.ts | 151 +++++ .../routes/dashboard/Dashboard.test.tsx | 340 ++++++++--- src/components/routes/dashboard/Dashboard.tsx | 356 ++++++++--- .../dashboard/DashboardContentTable.tsx | 190 ++++++ .../routes/dashboard/dashboardApi.test.ts | 101 ++++ .../routes/dashboard/dashboardApi.ts | 127 ++++ .../routes/dashboard/dashboardTable.ts | 169 ++++++ .../routes/dashboard/dashboardTableUi.tsx | 85 +++ .../series-details/SeriesDetailsPage.test.tsx | 85 +++ .../series-details/SeriesDetailsPage.tsx | 240 ++++++++ .../series-details/SeriesPlanRowActions.tsx | 30 + .../series-details/SeriesPlansTable.tsx | 217 +++++++ .../seriesDetailsMappers.test.ts | 50 ++ .../series-details/seriesDetailsMappers.ts | 185 ++++++ .../series-details/seriesDetailsTypes.ts | 15 + .../dashboard-table/DashBoardTable.tsx | 9 +- .../dropdown-button/DropdownButton.tsx | 180 ++++-- .../modals/plan-delete/PlanDeleteDialog.tsx | 18 +- .../ui/molecules/series-table/SeriesTable.tsx | 198 +++++++ .../shared-form-fields/SharedFormFields.tsx | 171 ++++++ .../ui/molecules/tag-input/TagInput.tsx | 9 +- src/main.tsx | 32 +- src/routes/paths.ts | 3 + src/schema/SeriesSchema.ts | 45 ++ 30 files changed, 3910 insertions(+), 263 deletions(-) create mode 100644 src/components/routes/create-series/CreateSeries.tsx create mode 100644 src/components/routes/create-series/api/planSearchApi.ts create mode 100644 src/components/routes/create-series/api/seriesApi.ts create mode 100644 src/components/routes/create-series/components/PlanSearchSelector.tsx create mode 100644 src/components/routes/create-series/hooks/useSeriesForm.ts create mode 100644 src/components/routes/dashboard/DashboardContentTable.tsx create mode 100644 src/components/routes/dashboard/dashboardApi.test.ts create mode 100644 src/components/routes/dashboard/dashboardApi.ts create mode 100644 src/components/routes/dashboard/dashboardTable.ts create mode 100644 src/components/routes/dashboard/dashboardTableUi.tsx create mode 100644 src/components/routes/series-details/SeriesDetailsPage.test.tsx create mode 100644 src/components/routes/series-details/SeriesDetailsPage.tsx create mode 100644 src/components/routes/series-details/SeriesPlanRowActions.tsx create mode 100644 src/components/routes/series-details/SeriesPlansTable.tsx create mode 100644 src/components/routes/series-details/seriesDetailsMappers.test.ts create mode 100644 src/components/routes/series-details/seriesDetailsMappers.ts create mode 100644 src/components/routes/series-details/seriesDetailsTypes.ts create mode 100644 src/components/ui/molecules/series-table/SeriesTable.tsx create mode 100644 src/components/ui/molecules/shared-form-fields/SharedFormFields.tsx create mode 100644 src/schema/SeriesSchema.ts diff --git a/src/components/routes/create-plan/CreatePlan.test.tsx b/src/components/routes/create-plan/CreatePlan.test.tsx index e546aa4a..ef2b7e75 100644 --- a/src/components/routes/create-plan/CreatePlan.test.tsx +++ b/src/components/routes/create-plan/CreatePlan.test.tsx @@ -151,7 +151,7 @@ describe("CreatePlan Component", () => { renderWithProviders(); expect( - screen.getByText("studio.plan.form_field.details"), + screen.getByRole("heading", { name: "Plan Details" }), ).toBeInTheDocument(); }); @@ -555,8 +555,7 @@ describe("CreatePlan Component", () => { await waitFor(() => { expect(axiosInstance.put).toHaveBeenCalledWith( "/api/v1/cms/plans/plan-123", - expect.any(Object), - expect.any(Object), + expect.objectContaining({ title: "Updated Plan" }), ); }); }); @@ -588,7 +587,6 @@ describe("CreatePlan Component", () => { expect(putSpy).toHaveBeenCalledWith( "/api/v1/cms/plans/plan-123", expect.objectContaining({ start_date: null }), - expect.any(Object), ); }); }); diff --git a/src/components/routes/create-plan/CreatePlan.tsx b/src/components/routes/create-plan/CreatePlan.tsx index 14db40b9..3435e400 100644 --- a/src/components/routes/create-plan/CreatePlan.tsx +++ b/src/components/routes/create-plan/CreatePlan.tsx @@ -5,7 +5,6 @@ import { IoCalendarClearOutline, IoInformationCircleOutline, } from "react-icons/io5"; - import { useState, useRef, useEffect } from "react"; import { format } from "date-fns"; import { Textarea } from "@/components/ui/atoms/textarea"; @@ -35,12 +34,7 @@ import { } from "@/components/ui/atoms/tooltip"; export const getPlan = async (plan_id: string) => { - const accessToken = sessionStorage.getItem("accessToken"); - const { data } = await axiosInstance.get(`/api/v1/cms/plans/${plan_id}`, { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }); + const { data } = await axiosInstance.get(`/api/v1/cms/plans/${plan_id}`); return data; }; @@ -51,26 +45,15 @@ export const updatePlan = async ({ plan_id: string; formdata: z.infer; }) => { - const accessToken = sessionStorage.getItem("accessToken"); const { data } = await axiosInstance.put( `/api/v1/cms/plans/${plan_id}`, formdata, - { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }, ); return data; }; export const postPlan = async (formdata: z.infer) => { - const accessToken = sessionStorage.getItem("accessToken"); - const { data } = await axiosInstance.post(`/api/v1/cms/plans`, formdata, { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }); + const { data } = await axiosInstance.post(`/api/v1/cms/plans`, formdata); return data; }; @@ -155,7 +138,7 @@ const Createplan = () => { }); const updatePlanMutation = useMutation({ mutationFn: updatePlan, - onSuccess: (_) => { + onSuccess: () => { toast.success("Plan updated successfully!", { description: "Your plan has been updated and is now available.", }); @@ -210,13 +193,15 @@ const Createplan = () => { }); setIsImageDialogOpen(false); toast.success("Image uploaded successfully!"); - } catch (error: any) { - if (error?.response?.status === 413) { + } catch (error: unknown) { + if ( + (error as { response?: { status?: number } }).response?.status === 413 + ) { toast.error("Failed to update Image", { description: "file exceeds the maximum size of 1MB", }); } else { - console.error("Image upload failed:", error); + console.error("Image upload failed:", error as Error); toast.error("Failed to upload image"); } } finally { @@ -239,7 +224,7 @@ const Createplan = () => {

- {t("studio.plan.form_field.details")} + Plan {isCreateMode ? "Details" : "Edit"}

diff --git a/src/components/routes/create-series/CreateSeries.tsx b/src/components/routes/create-series/CreateSeries.tsx new file mode 100644 index 00000000..8cdfecf7 --- /dev/null +++ b/src/components/routes/create-series/CreateSeries.tsx @@ -0,0 +1,551 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useBlocker, useNavigate, useParams } from "react-router-dom"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useTranslate } from "@tolgee/react"; +import { IoMdAdd, IoMdClose } from "react-icons/io"; +import { IoInformationCircleOutline } from "react-icons/io5"; +import { toast } from "sonner"; +import { Pecha } from "@/components/ui/shadimport"; +import { Textarea } from "@/components/ui/atoms/textarea"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/atoms/tooltip"; +import ImageContentData from "@/components/ui/molecules/modals/image-upload/ImageContentData"; +import { uploadImageToS3 } from "@/components/routes/task/api/taskApi"; +import { PLAN_LANGUAGE } from "@/lib/constant"; +import type { LanguageCode } from "@/schema/SeriesSchema"; +import { useSeriesForm } from "@/components/routes/create-series/hooks/useSeriesForm"; +import PlanSearchSelector from "@/components/routes/create-series/components/PlanSearchSelector"; +import { + buildSeriesUpdateBody, + getSeries, + mapSeriesDetailToFormData, + postSeries, + putUpdateSeries, +} from "@/components/routes/create-series/api/seriesApi"; +import type { SeriesFormData } from "@/schema/SeriesSchema"; + +const CreateSeries = () => { + const { seriesId } = useParams<{ seriesId: string }>(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { t } = useTranslate(); + const isNew = !seriesId; + const { + form, + languages, + plans, + imageUrl, + addedLanguages, + availableLanguages, + canSubmit, + addLanguage, + removeLanguage, + setImageUrl, + } = useSeriesForm(); + + const { + data: seriesData, + isLoading: isSeriesLoading, + isError: isSeriesError, + error: seriesError, + } = useQuery({ + queryKey: ["series", seriesId], + queryFn: () => getSeries(seriesId!), + enabled: Boolean(seriesId) && !isNew, + refetchOnWindowFocus: false, + }); + + const seriesHydratedIdRef = useRef(null); + + useEffect(() => { + seriesHydratedIdRef.current = null; + }, [seriesId]); + + useEffect(() => { + if (isNew || !seriesData) return; + if (seriesHydratedIdRef.current === seriesData.id) return; + seriesHydratedIdRef.current = seriesData.id; + form.reset(mapSeriesDetailToFormData(seriesData)); + setImagePreview(seriesData.image || null); + setSelectedImage(null); + }, [isNew, seriesData, form]); + + const [activePlansLanguage, setActivePlansLanguage] = + useState(null); + const [selectedImage, setSelectedImage] = useState(null); + const [imagePreview, setImagePreview] = useState(null); + const [isImageDialogOpen, setIsImageDialogOpen] = useState(false); + const [isImageUploading, setIsImageUploading] = useState(false); + const [showNavigationDialog, setShowNavigationDialog] = useState(false); + const fileInputRef = useRef(null); + + const orderedAddedLanguages = useMemo( + () => + PLAN_LANGUAGE.map((l) => l.value as LanguageCode).filter( + (code) => languages[code] != null, + ), + [languages], + ); + + useEffect(() => { + if (orderedAddedLanguages.length === 0) { + setActivePlansLanguage(null); + return; + } + if ( + activePlansLanguage == null || + !orderedAddedLanguages.includes(activePlansLanguage) + ) { + setActivePlansLanguage(orderedAddedLanguages[0]); + } + }, [orderedAddedLanguages, activePlansLanguage]); + + const canUpdate = form.formState.isDirty; + const hasUnsavedChanges = canUpdate && !form.formState.isSubmitSuccessful; + + const blocker = useBlocker( + ({ currentLocation, nextLocation }) => + hasUnsavedChanges && currentLocation.pathname !== nextLocation.pathname, + ); + + useEffect(() => { + if (blocker.state === "blocked") { + setShowNavigationDialog(true); + } + }, [blocker.state]); + + const saveSeriesMutation = useMutation({ + mutationFn: async (input: { data: SeriesFormData; featured: boolean }) => { + const body = buildSeriesUpdateBody(input.data, input.featured); + if (isNew) { + const created = await postSeries(body); + return { id: String(created.id) }; + } + await putUpdateSeries({ + seriesId: seriesId!, + body, + }); + return { id: seriesId! }; + }, + onSuccess: () => { + toast.success( + isNew ? "Series created successfully!" : "Series updated successfully!", + ); + void queryClient.invalidateQueries({ queryKey: ["dashboard-items"] }); + if (seriesId && !isNew) { + void queryClient.invalidateQueries({ queryKey: ["series", seriesId] }); + } + if (isNew) { + form.reset({ languages: {}, plans: {}, image_url: "" }); + setSelectedImage(null); + setImagePreview(null); + setActivePlansLanguage(null); + } + navigate("/dashboard"); + }, + onError: (error: Error) => { + toast.error( + isNew ? "Failed to create series" : "Failed to update series", + { + description: error.message, + }, + ); + }, + }); + + const handleConfirmNavigation = () => { + setShowNavigationDialog(false); + blocker.proceed?.(); + }; + + const handleCancelNavigation = () => { + setShowNavigationDialog(false); + blocker.reset?.(); + }; + + const handleRemoveImage = () => { + setSelectedImage(null); + setImagePreview(null); + setImageUrl(""); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + }; + + const handleImageUpload = async (file: File) => { + setIsImageUploading(true); + try { + const { image, key } = await uploadImageToS3( + file, + isNew ? "" : seriesId || "", + ); + const imageUrlUploaded = image.original; + setImagePreview(imageUrlUploaded); + setSelectedImage(file); + setImageUrl(key); + setIsImageDialogOpen(false); + toast.success("Image uploaded successfully!"); + } catch (error: unknown) { + const err = error as { response?: { status?: number } }; + if (err?.response?.status === 413) { + toast.error("Failed to upload image", { + description: "File exceeds the maximum size of 1MB", + }); + } else { + console.error("Image upload failed:", error); + toast.error("Failed to upload image"); + } + } finally { + setIsImageUploading(false); + } + }; + + const onSubmit = form.handleSubmit((data) => { + const featured = isNew ? false : (seriesData?.featured ?? false); + saveSeriesMutation.mutate({ data, featured }); + }); + + if (!isNew && isSeriesLoading) { + return ( +
+ Loading series… +
+ ); + } + + if (!isNew && isSeriesError) { + return ( +
+

+ {(seriesError as Error)?.message || "Could not load this series."} +

+ navigate("/dashboard")}> + {t("common.button.cancel")} + +
+ ); + } + + const submitEnabled = + canSubmit && + !saveSeriesMutation.isPending && + imageUrl.trim().length > 0 && + (isNew || !!seriesData); + + const saveDisabled = !submitEnabled || (!isNew && !form.formState.isDirty); + + const saveLabel = saveSeriesMutation.isPending + ? isNew + ? "Creating…" + : "Saving…" + : isNew + ? "Create series" + : "Save changes"; + + return ( +
+
+

+ {isNew ? "Series details" : "Series Edit"} +

+ + +
+
+ {orderedAddedLanguages.map((code) => { + const label = + code === "EN" + ? "English" + : code === "BO" + ? "Tibetan" + : code === "ZH" + ? "Chinese" + : code; + + return ( +
+ + ( + + + {label} title + + + + + + + )} + /> + + ( + + + {label} description + + +