Skip to content

Commit cd85266

Browse files
committed
Implement start date handling in CreatePlan component and enhance series details with start date settings. Update tests to cover new functionality.
1 parent 4572ae4 commit cd85266

12 files changed

Lines changed: 258 additions & 46 deletions

src/components/routes/create-plan/CreatePlan.tsx

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,14 @@ const Createplan = () => {
161161
return seriesOptions.some((s) => s.id === planNewFromSeries.seriesId);
162162
}, [planNewFromSeries, isSeriesLoading, isSeriesError, seriesOptions]);
163163

164+
const lockStartDateFromSeries = useMemo(
165+
() =>
166+
isCreateMode &&
167+
planNewFromSeries != null &&
168+
planNewFromSeries.start_date !== undefined,
169+
[isCreateMode, planNewFromSeries],
170+
);
171+
164172
const pageHeading = useMemo(() => {
165173
if (!isCreateMode) return "Plan Edit";
166174
if (!planNewFromSeries) return "Plan Details";
@@ -213,8 +221,20 @@ const Createplan = () => {
213221
isSeriesLoading,
214222
isSeriesError,
215223
seriesOptions,
224+
form,
216225
]);
217226

227+
useEffect(() => {
228+
if (!isCreateMode || !planNewFromSeries) return;
229+
if (planNewFromSeries.start_date === undefined) return;
230+
231+
const mode = planNewFromSeries.start_date ? "specific" : "enroll";
232+
setStartDateMode(mode);
233+
form.setValue("start_date", planNewFromSeries.start_date, {
234+
shouldDirty: false,
235+
});
236+
}, [isCreateMode, planNewFromSeries, form]);
237+
218238
useEffect(() => {
219239
if (planId && planData) {
220240
form.reset({
@@ -590,6 +610,7 @@ const Createplan = () => {
590610
<Pecha.RadioGroup
591611
value={startDateMode}
592612
onValueChange={(v) => {
613+
if (lockStartDateFromSeries) return;
593614
const mode = v as "enroll" | "specific";
594615
setStartDateMode(mode);
595616
if (mode === "enroll") {
@@ -604,6 +625,7 @@ const Createplan = () => {
604625
<Pecha.RadioGroupItem
605626
value="enroll"
606627
id="start-date-enroll"
628+
disabled={lockStartDateFromSeries}
607629
/>
608630
<label
609631
htmlFor="start-date-enroll"
@@ -616,6 +638,7 @@ const Createplan = () => {
616638
<Pecha.RadioGroupItem
617639
value="specific"
618640
id="start-date-specific"
641+
disabled={lockStartDateFromSeries}
619642
/>
620643
<label
621644
htmlFor="start-date-specific"
@@ -633,7 +656,10 @@ const Createplan = () => {
633656
<Pecha.Button
634657
type="button"
635658
variant="outline"
636-
disabled={startDateMode !== "specific"}
659+
disabled={
660+
lockStartDateFromSeries ||
661+
startDateMode !== "specific"
662+
}
637663
className="h-12 w-full justify-start gap-2 px-3 font-normal rounded-md"
638664
>
639665
<IoCalendarClearOutline className="h-4 w-4 text-muted-foreground" />

src/components/routes/create-plan/PlanNewLegacyRedirect.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useEffect } from "react";
22
import { useLocation, useNavigate } from "react-router-dom";
3-
import { getSeries } from "@/components/routes/create-series/api/seriesApi";
3+
import { getSeries, resolveSeriesGroupId } from "@/components/routes/create-series/api/seriesApi";
44
import { parsePlanNewFromSeriesState } from "./planNewFromSeriesState";
55
import { ROUTES } from "@/routes/paths";
66

@@ -20,8 +20,9 @@ const PlanNewLegacyRedirect = () => {
2020
void getSeries(fromSeries.seriesId)
2121
.then((series) => {
2222
if (cancelled) return;
23-
if (series.group_id) {
24-
navigate(ROUTES.groupPlanNew(series.group_id), {
23+
const groupId = resolveSeriesGroupId(series);
24+
if (groupId) {
25+
navigate(ROUTES.groupPlanNew(groupId), {
2526
replace: true,
2627
state: location.state,
2728
});

src/components/routes/create-plan/planNewFromSeriesState.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,31 @@ describe("parsePlanNewFromSeriesState", () => {
1414
).toEqual({ seriesId: "series-1", language: "BO" });
1515
});
1616

17+
it("includes inherited start_date when provided", () => {
18+
expect(
19+
parsePlanNewFromSeriesState({
20+
seriesId: "series-1",
21+
language: "BO",
22+
start_date: "2026-04-30T00:00:00Z",
23+
}),
24+
).toEqual({
25+
seriesId: "series-1",
26+
language: "BO",
27+
start_date: "2026-04-30T00:00:00Z",
28+
});
29+
expect(
30+
parsePlanNewFromSeriesState({
31+
seriesId: "series-1",
32+
language: "BO",
33+
start_date: null,
34+
}),
35+
).toEqual({
36+
seriesId: "series-1",
37+
language: "BO",
38+
start_date: null,
39+
});
40+
});
41+
1742
it("rejects invalid language codes", () => {
1843
expect(
1944
parsePlanNewFromSeriesState({ seriesId: "series-1", language: "FR" }),

src/components/routes/create-plan/planNewFromSeriesState.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import type { LanguageCode } from "@/schema/SeriesSchema";
44
export type PlanNewFromSeriesState = {
55
seriesId: string;
66
language: LanguageCode;
7+
/** Inherited from the first plan in the series when other plans already exist. */
8+
start_date?: string | null;
79
};
810

911
const VALID_LANGUAGE_CODES = new Set(
@@ -24,8 +26,17 @@ export function parsePlanNewFromSeriesState(
2426
return null;
2527
}
2628

29+
const rawStartDate = (state as Record<string, unknown>).start_date;
30+
const start_date =
31+
rawStartDate === null
32+
? null
33+
: typeof rawStartDate === "string"
34+
? rawStartDate
35+
: undefined;
36+
2737
return {
2838
seriesId: seriesId.trim(),
2939
language: language as LanguageCode,
40+
...(start_date !== undefined ? { start_date } : {}),
3041
};
3142
}

src/components/routes/create-series/api/seriesApi.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export type SeriesPlanDTO = {
2727
title: string;
2828
description?: string | null;
2929
language: string;
30+
group_id?: string | null;
3031
image_url?: string | null;
3132
plan_image_url?: string | null;
3233
image_key?: string | null;
@@ -45,6 +46,7 @@ export type SeriesPlanDTO = {
4546
enrolled_count?: number | null;
4647
subscription_count?: number | null;
4748
updated_at?: string | null;
49+
start_date?: string | null;
4850
};
4951

5052
export type SeriesMetadataDTO = {
@@ -65,17 +67,31 @@ export type SeriesDetailDTO = {
6567
image_key?: string | null;
6668
author_id?: string;
6769
group_id?: string | null;
70+
group?: { id?: string } | null;
6871
featured: boolean;
6972
status: string;
7073
plans: SeriesPlanDTO[];
7174
total_days?: number;
7275
};
7376

77+
export function resolveSeriesGroupId(
78+
series: Pick<SeriesDetailDTO, "group_id" | "group" | "plans"> | null | undefined,
79+
): string | undefined {
80+
if (!series) return undefined;
81+
if (series.group_id?.trim()) return series.group_id.trim();
82+
if (series.group?.id?.trim()) return series.group.id.trim();
83+
const fromPlan = series.plans?.find((p) => p.group_id?.trim())?.group_id;
84+
return fromPlan?.trim() || undefined;
85+
}
86+
7487
export const getSeries = async (seriesId: string): Promise<SeriesDetailDTO> => {
7588
const { data } = await axiosInstance.get<SeriesDetailDTO>(
7689
`/api/v1/cms/series/${seriesId}`,
7790
);
78-
return data;
91+
return {
92+
...data,
93+
group_id: resolveSeriesGroupId(data) ?? null,
94+
};
7995
};
8096

8197
export const postSeries = async (body: SeriesPayload) => {

src/components/routes/create-series/hooks/useCreateSeriesController.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { useSaveSeries } from "@/components/routes/create-series/hooks/useSaveSe
1212
import {
1313
getSeries,
1414
mapSeriesDetailToFormData,
15+
resolveSeriesGroupId,
1516
} from "@/components/routes/create-series/api/seriesApi";
1617
import { resolveDashboardItemImageUrl } from "@/components/routes/dashboard/dashboardTable";
1718

@@ -40,7 +41,7 @@ export const useCreateSeriesController = () => {
4041
});
4142
const seriesData = seriesQuery.data;
4243

43-
const seriesGroupId = groupId ?? seriesData?.group_id ?? undefined;
44+
const seriesGroupId = groupId ?? resolveSeriesGroupId(seriesData);
4445
const seriesStatus = isNew ? "DRAFT" : (seriesData?.status ?? "DRAFT");
4546
const {
4647
userInfo,

src/components/routes/series-details/CloneLanguagePlansPanel.tsx

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ type CloneLanguagePlansPanelProps = {
1111
seriesId: string;
1212
targetLanguage: LanguageCode;
1313
sourceLanguages: LanguageCode[];
14+
embedded?: boolean;
1415
};
1516

1617
function languageLabel(code: LanguageCode): string {
@@ -21,7 +22,8 @@ export function CloneLanguagePlansPanel({
2122
seriesId,
2223
targetLanguage,
2324
sourceLanguages,
24-
}: CloneLanguagePlansPanelProps) {
25+
embedded = false,
26+
}: Readonly<CloneLanguagePlansPanelProps>) {
2527
const queryClient = useQueryClient();
2628
const [sourceLanguage, setSourceLanguage] = useState<LanguageCode>(
2729
sourceLanguages[0] ?? "EN",
@@ -59,8 +61,8 @@ export function CloneLanguagePlansPanel({
5961

6062
if (sourceLanguages.length === 0) return null;
6163

62-
return (
63-
<div className="mb-4 flex flex-col items-center justify-center gap-4 rounded-xl border border-dashed border-gray-300 bg-white/80 px-6 py-10 dark:border-input dark:bg-[#1d1d1f]/80">
64+
const content = (
65+
<>
6466
<p className="text-center text-sm text-muted-foreground">
6567
No plans in {languageLabel(targetLanguage)} yet. Clone the full plan
6668
structure from another language.
@@ -92,6 +94,16 @@ export function CloneLanguagePlansPanel({
9294
{cloneMutation.isPending ? "Cloning…" : "Clone plans"}
9395
</Pecha.Button>
9496
</div>
97+
</>
98+
);
99+
100+
if (embedded) {
101+
return <div className="flex w-full flex-col items-center gap-4">{content}</div>;
102+
}
103+
104+
return (
105+
<div className="flex flex-col items-center justify-center gap-4 rounded-xl border border-dashed border-gray-300 bg-white/80 px-6 py-10 dark:border-input dark:bg-[#1d1d1f]/80">
106+
{content}
95107
</div>
96108
);
97109
}

src/components/routes/series-details/SeriesDetailsPage.test.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ vi.mock(
1515

1616
const seriesFixture = {
1717
id: "series-1",
18-
group_id: "group-1",
18+
group: { id: "group-1" },
1919
metadata: [{ id: "m1", title: "Abhidhamma in a year", language: "EN" }],
2020
featured: false,
2121
status: "DRAFT",
@@ -35,6 +35,8 @@ const seriesFixture = {
3535
status: "PUBLISHED",
3636
total_days: 5,
3737
featured: true,
38+
display_order: 0,
39+
start_date: "2026-04-30T00:00:00Z",
3840
},
3941
],
4042
};
@@ -110,7 +112,11 @@ describe("SeriesDetailsPage", () => {
110112

111113
await waitFor(() => {
112114
expect(screen.getByTestId("plan-new-location-state")).toHaveTextContent(
113-
JSON.stringify({ seriesId: "series-1", language: "ZH" }),
115+
JSON.stringify({
116+
seriesId: "series-1",
117+
language: "ZH",
118+
start_date: "2026-04-30T00:00:00Z",
119+
}),
114120
);
115121
expect(screen.getByTestId("plan-new-pathname")).toHaveTextContent(
116122
"/groups/group-1/plan/new",

0 commit comments

Comments
 (0)