From 68fb272e67027b19dec43a94d612408b2a121c3d Mon Sep 17 00:00:00 2001 From: Isha Dewangan Date: Fri, 17 Jul 2026 15:35:06 +0530 Subject: [PATCH 1/2] feat: implement theme persistence with localStorage and add tests --- dashboard/index.html | 6 +++ dashboard/src/faculty/FacultySettingsPage.tsx | 22 +---------- dashboard/src/lib/theme.ts | 20 ++++++++++ dashboard/src/pages/SettingsPage.tsx | 22 +---------- e2e/tests/student-theme.spec.ts | 38 +++++++++++++++++++ playwright.config.ts | 2 +- 6 files changed, 69 insertions(+), 41 deletions(-) create mode 100644 dashboard/src/lib/theme.ts create mode 100644 e2e/tests/student-theme.spec.ts diff --git a/dashboard/index.html b/dashboard/index.html index 7da8104..893b4a0 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -5,6 +5,12 @@ CS17 Portal + diff --git a/dashboard/src/faculty/FacultySettingsPage.tsx b/dashboard/src/faculty/FacultySettingsPage.tsx index e91516e..88c6d0b 100644 --- a/dashboard/src/faculty/FacultySettingsPage.tsx +++ b/dashboard/src/faculty/FacultySettingsPage.tsx @@ -9,29 +9,11 @@ import { DialogFooter, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; - -function getInitialTheme(): "light" | "dark" { - try { - const stored = localStorage.getItem("theme"); - if (stored === "dark" || stored === "light") return stored; - } catch {} - return document.documentElement.classList.contains("dark") ? "dark" : "light"; -} - -function applyTheme(theme: "light" | "dark") { - if (theme === "dark") { - document.documentElement.classList.add("dark"); - } else { - document.documentElement.classList.remove("dark"); - } - try { - localStorage.setItem("theme", theme); - } catch {} -} +import { applyTheme, getStoredTheme, type Theme } from "@/lib/theme"; export default function FacultySettingsPage() { const { profile } = useCurrentProfile(); - const [theme, setTheme] = useState<"light" | "dark">(getInitialTheme); + const [theme, setTheme] = useState(getStoredTheme); const [showLogoutDialog, setShowLogoutDialog] = useState(false); function toggleTheme() { diff --git a/dashboard/src/lib/theme.ts b/dashboard/src/lib/theme.ts new file mode 100644 index 0000000..5bac4c2 --- /dev/null +++ b/dashboard/src/lib/theme.ts @@ -0,0 +1,20 @@ +export type Theme = "light" | "dark"; + +export function getStoredTheme(): Theme { + try { + const stored = localStorage.getItem("theme"); + if (stored === "dark" || stored === "light") return stored; + } catch {} + return document.documentElement.classList.contains("dark") ? "dark" : "light"; +} + +export function applyTheme(theme: Theme): void { + setThemeClass(theme); + try { + localStorage.setItem("theme", theme); + } catch {} +} + +function setThemeClass(theme: Theme): void { + document.documentElement.classList.toggle("dark", theme === "dark"); +} diff --git a/dashboard/src/pages/SettingsPage.tsx b/dashboard/src/pages/SettingsPage.tsx index 7db5428..29c14ae 100644 --- a/dashboard/src/pages/SettingsPage.tsx +++ b/dashboard/src/pages/SettingsPage.tsx @@ -9,29 +9,11 @@ import { DialogFooter, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; - -function getInitialTheme(): "light" | "dark" { - try { - const stored = localStorage.getItem("theme"); - if (stored === "dark" || stored === "light") return stored; - } catch {} - return document.documentElement.classList.contains("dark") ? "dark" : "light"; -} - -function applyTheme(theme: "light" | "dark") { - if (theme === "dark") { - document.documentElement.classList.add("dark"); - } else { - document.documentElement.classList.remove("dark"); - } - try { - localStorage.setItem("theme", theme); - } catch {} -} +import { applyTheme, getStoredTheme, type Theme } from "@/lib/theme"; export default function SettingsPage() { const { student } = useCurrentStudent(); - const [theme, setTheme] = useState<"light" | "dark">(getInitialTheme); + const [theme, setTheme] = useState(getStoredTheme); const [showLogoutDialog, setShowLogoutDialog] = useState(false); function toggleTheme() { diff --git a/e2e/tests/student-theme.spec.ts b/e2e/tests/student-theme.spec.ts new file mode 100644 index 0000000..fc86a6b --- /dev/null +++ b/e2e/tests/student-theme.spec.ts @@ -0,0 +1,38 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Theme persistence", () => { + test("keeps dark mode across a refresh and on other pages", async ({ page }) => { + await page.goto("/dashboard/settings"); + await page.getByRole("switch").click(); + await expect(page.locator("html")).toHaveClass(/dark/); + + await page.reload(); + await expect(page.locator("html")).toHaveClass(/dark/); + await expect(page.getByRole("switch")).toHaveAttribute("aria-checked", "true"); + + await page.goto("/dashboard/projects"); + await expect(page.locator("html")).toHaveClass(/dark/); + + await page.goto("/dashboard/settings"); + await page.getByRole("switch").click(); + await page.reload(); + await expect(page.locator("html")).not.toHaveClass(/dark/); + }); + + test("applies dark before the app bundle runs, so there is no flash", async ({ + page, + }) => { + await page.goto("/dashboard/settings"); + await page.getByRole("switch").click(); + await expect(page.locator("html")).toHaveClass(/dark/); + + await page.route("**/dashboard/assets/*.js", async (route) => { + await new Promise((resolve) => setTimeout(resolve, 2000)); + await route.continue(); + }); + + await page.goto("/dashboard/projects", { waitUntil: "commit" }); + await expect(page.locator("html")).toHaveClass(/dark/, { timeout: 1000 }); + await expect(page.locator("#root")).toBeEmpty(); + }); +}); diff --git a/playwright.config.ts b/playwright.config.ts index 52c9900..895dfeb 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -4,7 +4,7 @@ import path from "path"; const authFile = path.join(__dirname, "e2e", ".auth", "user.json"); const studentAuthFile = path.join(__dirname, "e2e", ".auth", "student.json"); const facultyAuthFile = path.join(__dirname, "e2e", ".auth", "faculty.json"); -const studentSpecs = /student-submission\.spec\.ts/; +const studentSpecs = /(student-submission|student-theme)\.spec\.ts/; const facultySpecs = /faculty-assignments-ui\.spec\.ts/; const SITE_HOST = process.env.SITE_HOST || "cs17.portal:8000"; From e772958df92b54457d5e5eda18a158c06ebaef4e Mon Sep 17 00:00:00 2001 From: Isha Dewangan Date: Mon, 20 Jul 2026 10:09:17 +0530 Subject: [PATCH 2/2] feat: add faculty profile editing functionality and UI components --- cs17_portal/api.py | 21 ++- cs17_portal/www/dashboard.py | 10 +- dashboard/src/faculty/FacultyProfileCard.tsx | 168 ++++++++++++++++++ dashboard/src/faculty/FacultySettingsPage.tsx | 23 +-- dashboard/src/hooks/useCurrentProfile.ts | 28 ++- e2e/tests/faculty-profile-ui.spec.ts | 38 ++++ e2e/tests/faculty-profile.spec.ts | 83 +++++++++ playwright.config.ts | 2 +- 8 files changed, 345 insertions(+), 28 deletions(-) create mode 100644 dashboard/src/faculty/FacultyProfileCard.tsx create mode 100644 e2e/tests/faculty-profile-ui.spec.ts create mode 100644 e2e/tests/faculty-profile.spec.ts diff --git a/cs17_portal/api.py b/cs17_portal/api.py index 7930cb5..867fa07 100644 --- a/cs17_portal/api.py +++ b/cs17_portal/api.py @@ -18,11 +18,30 @@ def get_user_profile() -> dict | None: return frappe.db.get_value( "CS17 Profile", {"user": frappe.session.user}, - ["name", "full_name", "profile_type", "cohort", "profile_picture"], + ["name", "full_name", "first_name", "last_name", "profile_type", "cohort", "profile_picture"], as_dict=True, ) +@frappe.whitelist(methods=["POST"]) +def update_my_profile(first_name: str, last_name: str, profile_picture: str = "") -> dict: + profile = validate_membership("Faculty") + doc = frappe.get_doc("CS17 Profile", profile) + doc.update( + { + "first_name": first_name, + "last_name": last_name, + "profile_picture": profile_picture or None, + } + ) + doc.save(ignore_permissions=True) + return { + "name": doc.name, + "full_name": doc.full_name, + "profile_picture": doc.profile_picture, + } + + @frappe.whitelist(methods=["GET"]) def get_faculty_assignments(cohort: str | None = None) -> list: validate_membership("Faculty") diff --git a/cs17_portal/www/dashboard.py b/cs17_portal/www/dashboard.py index df3129d..113412e 100644 --- a/cs17_portal/www/dashboard.py +++ b/cs17_portal/www/dashboard.py @@ -32,7 +32,15 @@ def get_boot(): profiles = frappe.get_list( "CS17 Profile", filters={"user": current_user}, - fields=["name", "full_name", "profile_type", "cohort", "profile_picture"], + fields=[ + "name", + "full_name", + "first_name", + "last_name", + "profile_type", + "cohort", + "profile_picture", + ], limit=1, ignore_permissions=True, ) diff --git a/dashboard/src/faculty/FacultyProfileCard.tsx b/dashboard/src/faculty/FacultyProfileCard.tsx new file mode 100644 index 0000000..9e7d9c4 --- /dev/null +++ b/dashboard/src/faculty/FacultyProfileCard.tsx @@ -0,0 +1,168 @@ +import { useRef, useState } from "react"; +import { Check, Loader2, Pencil, X } from "lucide-react"; +import { useFrappeFileUpload, useFrappePostCall } from "frappe-react-sdk"; +import { + updateCurrentProfile, + useCurrentProfile, +} from "@/hooks/useCurrentProfile"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; + +const UPDATE_PROFILE = "cs17_portal.api.update_my_profile"; + +export default function FacultyProfileCard() { + const { profile } = useCurrentProfile(); + const fileInput = useRef(null); + const [editingName, setEditingName] = useState(false); + const [firstName, setFirstName] = useState(profile?.first_name ?? ""); + const [lastName, setLastName] = useState(profile?.last_name ?? ""); + const [error, setError] = useState(null); + const { upload, loading: uploading } = useFrappeFileUpload(); + const { call, loading: saving } = useFrappePostCall(UPDATE_PROFILE); + + async function save(patch: { first_name?: string; last_name?: string; profile_picture?: string }) { + setError(null); + const payload = { + first_name: patch.first_name ?? profile?.first_name ?? "", + last_name: patch.last_name ?? profile?.last_name ?? "", + profile_picture: patch.profile_picture ?? profile?.profile_picture ?? "", + }; + try { + const saved = await call(payload); + updateCurrentProfile({ ...payload, full_name: saved.message.full_name }); + return true; + } catch (err: any) { + setError(err?.message ?? "Could not save your profile. Please try again."); + return false; + } + } + + async function handlePhotoChange(file: File | null) { + if (!file) return; + setError(null); + if (!file.type.startsWith("image/")) { + setError("Choose an image file."); + return; + } + try { + const uploaded = await upload(file, { isPrivate: false }); + await save({ profile_picture: uploaded.file_url }); + } catch (err: any) { + setError(err?.message ?? "Could not upload the photo. Please try again."); + } + } + + function startEditingName() { + setFirstName(profile?.first_name ?? ""); + setLastName(profile?.last_name ?? ""); + setEditingName(true); + } + + async function saveName() { + if (!firstName.trim() || !lastName.trim()) { + setError("First and last name are both required."); + return; + } + const saved = await save({ + first_name: firstName.trim(), + last_name: lastName.trim(), + }); + if (saved) setEditingName(false); + } + + const busy = uploading || saving; + + return ( +
+
+
+ {profile?.profile_picture ? ( + Profile photo + ) : ( +
+ {profile?.full_name?.[0] ?? "?"} +
+ )} + + {profile?.profile_picture && ( + + )} + {uploading && ( +
+ +
+ )} + handlePhotoChange(event.target.files?.[0] ?? null)} + /> +
+ + {editingName ? ( +
+ setFirstName(event.target.value)} + className="h-8 w-28" + /> + setLastName(event.target.value)} + className="h-8 w-28" + /> + +
+ ) : ( +
+
+

{profile?.full_name ?? "—"}

+ +
+

Faculty

+
+ )} +
+ {error &&

{error}

} +
+ ); +} diff --git a/dashboard/src/faculty/FacultySettingsPage.tsx b/dashboard/src/faculty/FacultySettingsPage.tsx index 88c6d0b..0d47592 100644 --- a/dashboard/src/faculty/FacultySettingsPage.tsx +++ b/dashboard/src/faculty/FacultySettingsPage.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { useCurrentProfile } from "@/hooks/useCurrentProfile"; +import FacultyProfileCard from "@/faculty/FacultyProfileCard"; import { Dialog, DialogContent, @@ -12,7 +12,6 @@ import { Button } from "@/components/ui/button"; import { applyTheme, getStoredTheme, type Theme } from "@/lib/theme"; export default function FacultySettingsPage() { - const { profile } = useCurrentProfile(); const [theme, setTheme] = useState(getStoredTheme); const [showLogoutDialog, setShowLogoutDialog] = useState(false); @@ -38,25 +37,7 @@ export default function FacultySettingsPage() {

Profile

-
-
- {profile?.profile_picture ? ( - {profile?.full_name} - ) : ( -
- {profile?.full_name?.[0] ?? "?"} -
- )} -
-

{profile?.full_name ?? "—"}

-

Faculty

-
-
-
+
diff --git a/dashboard/src/hooks/useCurrentProfile.ts b/dashboard/src/hooks/useCurrentProfile.ts index 7000e33..ece46e8 100644 --- a/dashboard/src/hooks/useCurrentProfile.ts +++ b/dashboard/src/hooks/useCurrentProfile.ts @@ -1,17 +1,37 @@ +import { useSyncExternalStore } from "react"; + interface Profile { name: string; full_name: string; + first_name: string; + last_name: string; profile_type: "Student" | "Faculty"; cohort: string | null; profile_picture: string; } -export function useCurrentProfile() { - const boot = (window as any).frappe_boot; - const profile: Profile | null = boot?.profile ?? null; +const getBoot = () => (window as any).frappe_boot; + +let profile: Profile | null = getBoot()?.profile ?? null; +const listeners = new Set<() => void>(); +function subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export function updateCurrentProfile(patch: Partial) { + if (!profile) return; + profile = { ...profile, ...patch }; + if (getBoot()) getBoot().profile = profile; + listeners.forEach((listener) => listener()); +} + +export function useCurrentProfile() { return { - profile, + profile: useSyncExternalStore(subscribe, () => profile), isLoading: false, error: null, }; diff --git a/e2e/tests/faculty-profile-ui.spec.ts b/e2e/tests/faculty-profile-ui.spec.ts new file mode 100644 index 0000000..1fbc16e --- /dev/null +++ b/e2e/tests/faculty-profile-ui.spec.ts @@ -0,0 +1,38 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Faculty profile settings", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/dashboard/faculty/settings"); + }); + + test("renames the faculty member from the profile card", async ({ page }) => { + await page.getByRole("button", { name: "Edit name" }).click(); + await page.getByLabel("First name").fill("Renamed"); + await page.getByLabel("Last name").fill("Faculty"); + + await page.getByRole("button", { name: "Save name" }).click(); + await expect(page.getByText("Renamed Faculty").first()).toBeVisible(); + await page.reload(); + await expect(page.getByText("Renamed Faculty").first()).toBeVisible(); + }); + + test("uploads and removes a profile photo", async ({ page }) => { + const fileChooserPromise = page.waitForEvent("filechooser"); + await page.getByRole("button", { name: /Upload photo|Change photo/ }).click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles({ + name: "avatar.png", + mimeType: "image/png", + buffer: Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", + "base64", + ), + }); + + const avatar = page.getByRole("img", { name: "Profile photo" }); + await expect(avatar).toHaveAttribute("src", /\/files\//); + + await page.getByRole("button", { name: "Remove photo" }).click(); + await expect(page.getByRole("button", { name: "Upload photo" })).toBeVisible(); + }); +}); diff --git a/e2e/tests/faculty-profile.spec.ts b/e2e/tests/faculty-profile.spec.ts new file mode 100644 index 0000000..aaaad23 --- /dev/null +++ b/e2e/tests/faculty-profile.spec.ts @@ -0,0 +1,83 @@ +import { test, expect } from "@playwright/test"; +import { CS17Profile, ensureSessionFaculty, getProfileForUser } from "../helpers/cs17"; +import { callMethod, deleteDoc, getDoc, updateDoc } from "../helpers/frappe"; + +const UPDATE = "cs17_portal.api.update_my_profile"; + +interface UpdatedProfile { + name: string; + full_name: string; + profile_picture: string | null; +} + +test.describe("Faculty profile editing", () => { + let profileName: string; + let createdProfile: string | null = null; + let original: CS17Profile; + + test.beforeAll(async ({ request }) => { + createdProfile = await ensureSessionFaculty(request); + const profile = await getProfileForUser(request, "Administrator"); + profileName = profile!.name; + original = await getDoc(request, "CS17 Profile", profileName); + }); + + test.afterAll(async ({ request }) => { + if (createdProfile) { + await deleteTestProfileSafely(request, createdProfile); + return; + } + await updateDoc(request, "CS17 Profile", profileName, { + first_name: original.first_name, + last_name: original.last_name, + profile_picture: original.profile_picture ?? null, + }); + }); + + async function deleteTestProfileSafely(request: any, name: string) { + try { + await deleteDoc(request, "CS17 Profile", name); + } catch (error) { + console.warn(`Failed to delete profile ${name}:`, error); + } + } + + test("saves the name and derives full_name", async ({ request }) => { + const updated = await callMethod(request, UPDATE, { + first_name: "Renamed", + last_name: "Faculty", + }); + + expect(updated.full_name).toBe("Renamed Faculty"); + const doc = await getDoc(request, "CS17 Profile", profileName); + expect(doc.first_name).toBe("Renamed"); + expect(doc.last_name).toBe("Faculty"); + }); + + test("sets and clears the profile picture", async ({ request }) => { + await callMethod(request, UPDATE, { + first_name: "Photo", + last_name: "Faculty", + profile_picture: "/files/e2e-avatar.png", + }); + let doc = await getDoc(request, "CS17 Profile", profileName); + expect(doc.profile_picture).toBe("/files/e2e-avatar.png"); + + await callMethod(request, UPDATE, { + first_name: "Photo", + last_name: "Faculty", + profile_picture: "", + }); + doc = await getDoc(request, "CS17 Profile", profileName); + expect(doc.profile_picture).toBeFalsy(); + }); + + test("rejects a blank last name", async ({ request }) => { + await expect( + callMethod(request, UPDATE, { + first_name: "OnlyFirst", + last_name: "", + }), + ).rejects.toThrow(); + }); +}); diff --git a/playwright.config.ts b/playwright.config.ts index 895dfeb..0998c2a 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -5,7 +5,7 @@ const authFile = path.join(__dirname, "e2e", ".auth", "user.json"); const studentAuthFile = path.join(__dirname, "e2e", ".auth", "student.json"); const facultyAuthFile = path.join(__dirname, "e2e", ".auth", "faculty.json"); const studentSpecs = /(student-submission|student-theme)\.spec\.ts/; -const facultySpecs = /faculty-assignments-ui\.spec\.ts/; +const facultySpecs = /(faculty-assignments-ui|faculty-profile-ui)\.spec\.ts/; const SITE_HOST = process.env.SITE_HOST || "cs17.portal:8000"; const SITE_DOMAIN = SITE_HOST.split(":")[0];