Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ jobs:
lower_owner=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')
docker build \
--build-arg VITE_ENV_SALT=${{ secrets.VITE_ENV_SALT }} \
--build-arg VITE_YOUTUBE_API_KEY=${{ secrets.VITE_YOUTUBE_API_KEY }} \
-t ghcr.io/$lower_owner/plan_frontend:${{ env.IMAGE_TAG }} .

- name: Push Docker image to GHCR
Expand Down
4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ RUN npm install

COPY . .
ARG VITE_ENV_SALT
ARG VITE_YOUTUBE_API_KEY
ENV VITE_ENV_SALT=${VITE_ENV_SALT}
ENV VITE_YOUTUBE_API_KEY=${VITE_YOUTUBE_API_KEY}
RUN npm run build

FROM nginx:stable-alpine
Expand All @@ -31,4 +33,4 @@ COPY nginx/security-headers.conf /etc/nginx/

EXPOSE 4173

CMD ["sh", "-c", "envsubst '${VITE_BACKEND_BASE_URL}' < /etc/nginx/conf.d/studio.conf.template > /etc/nginx/conf.d/default.conf && nginx -g 'daemon off;'"]
CMD ["sh", "-c", "envsubst '${VITE_BACKEND_BASE_URL} ${VITE_YOUTUBE_API_KEY}' < /etc/nginx/conf.d/studio.conf.template > /etc/nginx/conf.d/default.conf && nginx -g 'daemon off;'"]
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

47 changes: 47 additions & 0 deletions src/components/api/searchApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import axiosInstance from "@/config/axios-config";
import { LANGUAGE } from "@/lib/constant";

type SearchCommon = {
query: string;
language?: string;
limit?: number;
skip?: number;
};

const normalizeLanguageForApi = (language?: string | null) => {
if (!language) return "en";
const lower = language.toLowerCase();
if (lower.startsWith("en")) return "en";
if (lower.startsWith("bo")) return "bo";
if (lower.startsWith("zh")) return "zh";
return "en";
};

const pickSearchLanguage = (query: string, uiLanguage?: string | null) => {
const normalized = normalizeLanguageForApi(uiLanguage);
if (/[A-Za-z]/.test(query) && normalized !== "en") {
return "en";
}
return normalized;
};

export const searchSources = async ({
query,
language = localStorage.getItem(LANGUAGE) || "en",
limit = 10,
skip = 0,
}: SearchCommon) => {
const lang = pickSearchLanguage(query, language);
const { data } = await axiosInstance.get(`/api/v1/search/multilingual`, {
params: {
query,
search_type: "exact",
language: lang,
limit,
skip,
},
});
return data;
};

export type { SearchCommon };
10 changes: 4 additions & 6 deletions src/components/auth/login/Login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ const Login = () => {
const { t } = useTranslate();
const navigate = useNavigate();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [errors, setErrors] = useState<string>("");
const [successMessage, setSuccessMessage] = useState<string>("");
const [showEmailReverify, setShowEmailReverify] = useState<boolean>(false);
Expand Down Expand Up @@ -69,10 +68,12 @@ const Login = () => {
},
});

const handleLogin = (e: React.FormEvent) => {
const handleLogin = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setShowEmailReverify(false);
setSuccessMessage("");
const formData = new FormData(e.currentTarget);
const password = formData.get("password") as string;
const clientPassword = createPasswordHash(email, password);
loginMutation.mutate({
email,
Expand Down Expand Up @@ -111,13 +112,10 @@ const Login = () => {
</Label>
<Input
type="password"
name="password"
placeholder={t("studio.login.placeholder.password")}
className=" placeholder:text-[#b1b1b1]"
required
value={password}
onChange={(e) => {
setPassword(e.target.value);
}}
/>
</div>
<div className="flex mt-4 justify-center ">
Expand Down
6 changes: 1 addition & 5 deletions src/components/auth/reset-password/ResetPassword.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -153,11 +153,7 @@ describe("ResetPassword Component", () => {
{
password:
"4c2417eef23ec7fb1bd8d74eb29afe5f0867a4ddbdb707ada89d1cf91f0e6e1d",
},
{
headers: {
Authorization: "Bearer test-token",
},
token: "test-token",
},
);
});
Expand Down
51 changes: 7 additions & 44 deletions src/components/routes/create-plan/CreatePlan.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,14 @@ describe("CreatePlan Component", () => {
if (url.includes("/media/upload")) {
return Promise.resolve({
data: {
url: "mock-image-url",
image: {
thumbnail: "mock-thumb-url",
medium: "mock-medium-url",
original: "mock-image-url",
},
key: "mock-image-key",
path: "images/path",
message: "Image uploaded successfully",
},
});
}
Expand Down Expand Up @@ -376,47 +382,4 @@ describe("CreatePlan Component", () => {
fireEvent.click(confirmButton);
expect(mockBlocker.proceed).toHaveBeenCalled();
});

it("updates an existing plan successfully", async () => {
const mockPlanData = {
id: "any-plan-123",
title: "Existing Plan",
description: "Description",
total_days: 10,
difficulty_level: "intermediate",
image_url: "",
tags: [],
language: "en",
};
const mockUseParams = vi.fn().mockReturnValue({ plan_id: "any-plan-123" });
vi.mocked(useParams).mockImplementation(mockUseParams);
vi.spyOn(axiosInstance, "get").mockResolvedValue({
data: mockPlanData,
});
vi.spyOn(axiosInstance, "put").mockResolvedValue({
data: { ...mockPlanData, title: "Updated Plan" },
});
renderWithProviders(<CreatePlan />);
await waitFor(() => {
const titleInput = screen.getByPlaceholderText(
"studio.plan.form.placeholder.title",
) as HTMLInputElement;
expect(titleInput.value).toBe("Existing Plan");
});
const titleInput = screen.getByPlaceholderText(
"studio.plan.form.placeholder.title",
);
fireEvent.change(titleInput, { target: { value: "Updated Plan" } });
const submitButton = screen.getByText("studio.plan.update_button");
fireEvent.click(submitButton);
await waitFor(() => {
expect(axiosInstance.put).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/cms/plans/any-plan-123"),
expect.objectContaining({
title: "Updated Plan",
}),
expect.any(Object),
);
});
});
});
4 changes: 2 additions & 2 deletions src/components/routes/create-plan/CreatePlan.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -162,11 +162,11 @@ const Createplan = () => {
};
const handleImageUpload = async (file: File) => {
try {
const { url, key } = await uploadImageToS3(
const { image, key } = await uploadImageToS3(
file,
plan_id === "new" ? "" : plan_id || "",
);
const imageUrl = url;
const imageUrl = image.original;
const imageKey = key;
setImagePreview(imageUrl);
setSelectedImage(file);
Expand Down
9 changes: 0 additions & 9 deletions src/components/routes/dashboard/Dashboard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,15 +93,6 @@ describe("Dashboard Component", () => {
expect(coverImageHeader.tagName).toBe("TH");
});

it("renders pagination navigation", () => {
renderWithProviders(<Dashboard />);

const paginationNav = screen.getByRole("navigation", {
name: "pagination",
});
expect(paginationNav).toBeDefined();
});

it("fetches plans correctly and returns the correct data", async () => {
vi.spyOn(axiosInstance, "get").mockResolvedValue({
data: { plans: [], total: 0 },
Expand Down
33 changes: 32 additions & 1 deletion src/components/routes/dashboard/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@ import { useState, Activity } from "react";
import { useDebounce } from "use-debounce";
import { useTranslate } from "@tolgee/react";
import { Button } from "@/components/ui/atoms/button";
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import axiosInstance from "@/config/axios-config";
import { Link } from "react-router-dom";
import { Pagination } from "@/components/ui/molecules/pagination/Pagination";
import AuthButton from "@/components/ui/molecules/auth-button/AuthButton";
import { toast } from "sonner";

const fetchPlans = async (
page: number,
Expand All @@ -35,6 +36,19 @@ const fetchPlans = async (
return data;
};

const toggleFeatured = async (planId: string) => {
const accessToken = sessionStorage.getItem("accessToken");
const { data } = await axiosInstance.patch(
`/api/v1/cms/plans/${planId}/featured`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
return data;
};

const Dashboard = () => {
const { t } = useTranslate();
const [search, setSearch] = useState("");
Expand All @@ -51,6 +65,7 @@ const Dashboard = () => {
setSortOrder("asc");
}
};

const {
data: planData,
isLoading,
Expand All @@ -69,6 +84,21 @@ const Dashboard = () => {
retry: false,
});

const queryClient = useQueryClient();
const featuredMutation = useMutation({
mutationFn: (planId: string) => toggleFeatured(planId),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["dashboard-plans"] });
},
onError: (error: any) => {
toast.error(error.response.data.detail.message);
},
});

const handleFeatured = (planId: string) => {
featuredMutation.mutate(planId);
};

const totalPages = planData ? Math.ceil(planData.total / 10) : 1;

return (
Expand Down Expand Up @@ -102,6 +132,7 @@ const Dashboard = () => {
sortBy={sortBy}
sortOrder={sortOrder}
onSort={handleSort}
handleFeatured={handleFeatured}
/>
</div>
<Activity mode={planData?.plans?.length > 0 ? "visible" : "hidden"}>
Expand Down
34 changes: 14 additions & 20 deletions src/components/routes/task/PlanDetailsPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,12 +93,16 @@ Object.defineProperty(window, "sessionStorage", {
writable: true,
});

const renderWithProviders = (component: React.ReactElement) => {
const renderWithProviders = (component: React.ReactElement, isDraft = true) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
queryClient.setQueryData(["planDetails", "test-plan-id"], {
...mockPlanData,
status: isDraft ? "DRAFT" : "ARCHIVED",
});
return render(
<QueryClientProvider client={queryClient}>
<BrowserRouter>{component}</BrowserRouter>
Expand Down Expand Up @@ -149,7 +153,7 @@ describe("PlanDetailsPanel Component", () => {
it("calls API when Add New Day button is clicked", async () => {
const { default: axiosInstance } = await import("@/config/axios-config");
const mockAxios = axiosInstance as any;
renderWithProviders(<PlanDetailsPage />);
renderWithProviders(<PlanDetailsPage />, true);
await waitFor(() => {
expect(screen.getByText("Day 4")).toBeInTheDocument();
});
Expand All @@ -175,7 +179,7 @@ describe("PlanDetailsPanel Component", () => {
mockAxios.post.mockRejectedValueOnce({
response: { data: { detail: "Cannot create day" } },
});
renderWithProviders(<PlanDetailsPage />);
renderWithProviders(<PlanDetailsPage />, true);
await waitFor(() => {
expect(screen.getByText("Day 4")).toBeInTheDocument();
});
Expand All @@ -196,23 +200,15 @@ describe("PlanDetailsPanel Component", () => {
expect(screen.getByPlaceholderText("Task Title")).toBeInTheDocument();
});

it("switches to task view after creating a new task", async () => {
it("switches to task view after clicking a task", async () => {
const { default: axiosInstance } = await import("@/config/axios-config");
const mockAxios = axiosInstance as any;
mockAxios.post.mockResolvedValueOnce({
data: {
id: "newly-created-task-123",
title: "New Task",
display_order: 1,
estimated_time: 30,
},
});
mockAxios.get.mockImplementation((url: string) => {
if (url.includes("/tasks/newly-created-task-123")) {
if (url.includes("/tasks/task1")) {
return Promise.resolve({
data: {
id: "newly-created-task-123",
title: "New Task",
id: "task1",
title: "Morning Intention Setting",
display_order: 1,
estimated_time: 30,
subtasks: [],
Expand All @@ -221,19 +217,17 @@ describe("PlanDetailsPanel Component", () => {
}
return Promise.resolve({ data: mockPlanData });
});
renderWithProviders(<PlanDetailsPage />);
renderWithProviders(<PlanDetailsPage />, true);
await waitFor(() => {
expect(screen.getByText(mockPlanData.title)).toBeInTheDocument();
});
const titleInput = screen.getByPlaceholderText("Task Title");
fireEvent.change(titleInput, { target: { value: "New Task" } });
fireEvent.click(screen.getByText("Submit"));
expect(screen.getByText("Add Task")).toBeInTheDocument();
fireEvent.click(screen.getByText("Morning Intention Setting"));
await waitFor(() => {
expect(screen.queryByText("Add Task")).not.toBeInTheDocument();
});
await waitFor(() => {
expect(screen.getByText("Task")).toBeInTheDocument();
expect(screen.getByText("New Task")).toBeInTheDocument();
});
});
});
Loading