diff --git a/index.html b/index.html
index 1e308c9..1faf3c9 100644
--- a/index.html
+++ b/index.html
@@ -2,26 +2,25 @@
-
+
- refine - Build your React-based CRUD applications, without
- constraints.
+ Fern - UI | Test Report Generator Dashboard
diff --git a/package-lock.json b/package-lock.json
index be29fd5..7052b3a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -27,6 +27,7 @@
"axios": "^1.6.8",
"graphql-request": "^7.1.2",
"moment": "^2.30.1",
+ "moment-timezone": "^0.5.48",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react-query": "^3.39.3",
@@ -12990,6 +12991,17 @@
"node": "*"
}
},
+ "node_modules/moment-timezone": {
+ "version": "0.5.48",
+ "resolved": "https://artifactory.guidewire.com/artifactory/api/npm/jutro-suite-npm-dev/moment-timezone/-/moment-timezone-0.5.48.tgz",
+ "integrity": "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==",
+ "dependencies": {
+ "moment": "^2.29.4"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
"node_modules/mri": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
diff --git a/package.json b/package.json
index c634f74..4019071 100644
--- a/package.json
+++ b/package.json
@@ -24,6 +24,7 @@
"axios": "^1.6.8",
"graphql-request": "^7.1.2",
"moment": "^2.30.1",
+ "moment-timezone": "^0.5.48",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react-query": "^3.39.3",
diff --git a/public/fern-logo.png b/public/fern-logo.png
new file mode 100644
index 0000000..6fb40df
Binary files /dev/null and b/public/fern-logo.png differ
diff --git a/src/App.tsx b/src/App.tsx
index 510d434..2dc98aa 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -2,7 +2,7 @@ import {Refine,} from '@refinedev/core';
import {DevtoolsPanel, DevtoolsProvider} from "@refinedev/devtools";
import {RefineKbar, RefineKbarProvider} from "@refinedev/kbar";
-import {ErrorComponent, ThemedLayoutV2, ThemedSiderV2, ThemedTitleV2, useNotificationProvider} from '@refinedev/antd';
+import {ErrorComponent, ThemedLayoutV2, useNotificationProvider} from '@refinedev/antd';
import "@refinedev/antd/dist/reset.css";
import {App as AntdApp} from "antd"
@@ -13,14 +13,44 @@ import routerBindings, {
UnsavedChangesNotifier
} from "@refinedev/react-router-v6";
import {ColorModeContextProvider} from "./contexts/color-mode";
-import {Header} from "./components";
+import {Header, LoadingSpinner} from "./components";
import {graphqlDataProvider} from "./providers/testrun-graphql-provider";
import {summaryProvider} from "./providers/summary-provider";
import {TestRunsList} from "./pages/test-runs";
import {TestSummary} from "./pages/test-summaries";
+import {UserPreferencePage} from "./pages/user-preference"
+import { fetchUserPreference } from "./pages/user-preference/user-preference-utils";
+import { useContext, useEffect, useState } from 'react';
+import { ColorModeContext } from "../src/contexts/color-mode";
+import moment from 'moment-timezone';
-function App() {
+const NoSider: React.FC = () => null; // to hide the side-navbar
+
+function AppContent() {
+ const { setMode } = useContext(ColorModeContext);
+ const [timezone, setTimezone] = useState(moment.tz.guess());
+ const [isUserPreferencesLoaded, setIsUserPreferencesLoaded] = useState(false);
+ useEffect(() => {
+ const initUserPreferences = async () => {
+ try {
+ const userPref = await fetchUserPreference();
+ if (userPref) {
+ setMode(userPref.isDark ? "dark" : "light");
+ setTimezone(userPref.timezone);
+ }
+ } catch (err) {
+ console.error("Failed to load user preferences:", err);
+ } finally {
+ setIsUserPreferencesLoaded(true);
+ }
+ };
+ initUserPreferences();
+ }, []);
+
+ if (!isUserPreferencesLoaded) {
+ return ;
+ }
return (
@@ -56,6 +86,14 @@ function App() {
dataProviderName: "summaries",
},
},
+ {
+ name: "preferences",
+ list: "/preferences",
+ meta: {
+ parent: "Test Reports",
+ dataProviderName: "userpreference",
+ },
+ },
]}
options={{
syncWithLocation: true,
@@ -70,20 +108,14 @@ function App() {
element={(
}
- Sider={(props) => (
-
- )}
- Title={(props) => (
-
- )}
- initialSiderCollapsed={true}
+ Sider={NoSider}
>
)}
>
+
}/>
}/>
@@ -91,6 +123,9 @@ function App() {
} />
+
+ } />
+
}/>
@@ -108,4 +143,12 @@ function App() {
);
}
+function App() {
+ return (
+
+
+
+ );
+}
+
export default App;
diff --git a/src/components/header/index.tsx b/src/components/header/index.tsx
index 702298b..17e33ac 100644
--- a/src/components/header/index.tsx
+++ b/src/components/header/index.tsx
@@ -1,5 +1,6 @@
import type { RefineThemedLayoutV2HeaderProps } from "@refinedev/antd";
import { useGetIdentity } from "@refinedev/core";
+import { UserOutlined, SettingOutlined } from '@ant-design/icons';
import {
Avatar,
Layout as AntdLayout,
@@ -7,9 +8,17 @@ import {
Switch,
theme,
Typography,
+ Menu,
+ Dropdown,
+ Button
} from "antd";
-import React, { useContext } from "react";
+import { MenuOutlined } from '@ant-design/icons';
+import React, { useContext, useState, useEffect, useCallback } from "react";
import { ColorModeContext } from "../../contexts/color-mode";
+import { useLocation, useNavigate } from "react-router-dom";
+import { debounce } from "../../utils/debounce";
+
+
const { Text } = Typography;
const { useToken } = theme;
@@ -26,14 +35,30 @@ export const Header: React.FC = ({
const { token } = useToken();
const { data: user } = useGetIdentity();
const { mode, setMode } = useContext(ColorModeContext);
+ const navigate = useNavigate();
+ const location = useLocation();
+ const [windowWidth, setWindowWidth] = useState(window.innerWidth);
+
+ const handleResize = useCallback(() => {
+ setWindowWidth(window.innerWidth);
+ }, []);
+
+ const debouncedHandleResize = useCallback(
+ debounce(handleResize, 250),
+ [handleResize]
+ );
+
+ useEffect(() => {
+ window.addEventListener('resize', debouncedHandleResize);
+ return () => window.removeEventListener('resize', debouncedHandleResize);
+ }, [debouncedHandleResize]);
const headerStyles: React.CSSProperties = {
backgroundColor: token.colorBgElevated,
display: "flex",
- justifyContent: "flex-end",
- alignItems: "center",
- padding: "0px 24px",
- height: "64px",
+ flexDirection: "column",
+ padding: 0,
+ height: "auto",
};
if (sticky) {
@@ -42,9 +67,83 @@ export const Header: React.FC = ({
headerStyles.zIndex = 1;
}
+ const navTabs = [
+ { label: "Test Summary", key: "testsummaries", path: "/testsummaries" },
+ { label: "Test Run", key: "testruns", path: "/testruns" },
+
+ // add more tabs here as needed in the future
+ ];
+
+ const activeTab = navTabs.find(tab => location.pathname.startsWith(tab.path))?.key || navTabs[0].key;
+
+ const mobileMenuItems = navTabs.map(tab => ({
+ key: tab.key,
+ label: tab.label,
+ onClick: () => navigate(tab.path)
+ }));
+
+ // User dropdown menu items
+ const userMenuItems = [
+ {
+ key: 'preferences',
+ icon: ,
+ label: 'User Preferences',
+ onClick: () => navigate('/preferences')
+ }
+ ];
+
+ const isMobile = windowWidth < 768;
+
return (
-
+
+
+
+ {windowWidth > 480 && (
+ Fern Reporter
+ )}
+
+ {!isMobile ? (
+
+
+
= ({
setMode(mode === "light" ? "dark" : "light")
}
defaultChecked={mode === "dark"}
+ checked={mode === "dark"} // ensures that both switches stay in sync since they're both controlled by the same ColorModeContext state
/>
-
- {user?.name && {user.name}}
- {user?.avatar && (
-
- )}
+
+
+ {user?.name && {user.name}}
+ : null}
+ alt={user?.name}
+ />
+
+
-
+
);
};
diff --git a/src/components/index.ts b/src/components/index.ts
index 924cc88..21d1add 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -1 +1,2 @@
export { Header } from "./header";
+export { LoadingSpinner } from "./spinner"
diff --git a/src/components/spinner/index.tsx b/src/components/spinner/index.tsx
new file mode 100644
index 0000000..aa3e6f5
--- /dev/null
+++ b/src/components/spinner/index.tsx
@@ -0,0 +1,93 @@
+import { Spin } from 'antd';
+import { LoadingOutlined } from '@ant-design/icons';
+import { useEffect, useState } from 'react';
+
+export const LoadingSpinner = () => {
+ const [dots, setDots] = useState('');
+
+ // animate the dots
+ useEffect(() => {
+ const interval = setInterval(() => {
+ setDots(prev => {
+ if (prev.length >= 3) return '';
+ return prev + '.';
+ });
+ }, 400);
+
+ return () => clearInterval(interval);
+ }, []);
+
+ // brand coloured green spinner indicator with larger size
+ const antIcon = ;
+
+ // override the fullscreen spinner background color and add custom styling
+ useEffect(() => {
+ const styleElement = document.createElement('style');
+ styleElement.innerHTML = `
+ .ant-spin-fullscreen {
+ background-color: white !important;
+ }
+
+ @keyframes fadeIn {
+ 0% { opacity: 0; transform: translateY(10px); }
+ 100% { opacity: 1; transform: translateY(0); }
+ }
+
+ .loading-tip {
+ animation: fadeIn 0.5s ease-out;
+ font-weight: 500;
+ }
+
+ .text-with-dots {
+ position: relative;
+ display: inline-block;
+ }
+
+ .dot-container {
+ position: absolute;
+ left: 100%;
+ top: 0;
+ width: 36px;
+ text-align: left;
+ }
+ `;
+ document.head.appendChild(styleElement);
+
+ return () => {
+ document.head.removeChild(styleElement);
+ };
+ }, []);
+
+ return (
+
+
+
+ We're cooking up your test results.
+ {dots}
+
+
+
+ Almost there!
+
+
+ }
+ fullscreen
+ />
+ );
+};
diff --git a/src/pages/user-preference/index.tsx b/src/pages/user-preference/index.tsx
new file mode 100644
index 0000000..3c8bdef
--- /dev/null
+++ b/src/pages/user-preference/index.tsx
@@ -0,0 +1 @@
+export * from "./user-preference";
diff --git a/src/pages/user-preference/interface.ts b/src/pages/user-preference/interface.ts
new file mode 100644
index 0000000..934241c
--- /dev/null
+++ b/src/pages/user-preference/interface.ts
@@ -0,0 +1,15 @@
+export interface IUserPreference {
+ isDark: boolean;
+ timezone: string;
+}
+
+export interface IProjectGroup {
+ group_id?: number;
+ group_name: string;
+ projects: IProject[];
+}
+
+export interface IProject {
+ uuid: string;
+ name: string;
+}
diff --git a/src/pages/user-preference/user-preference-utils.tsx b/src/pages/user-preference/user-preference-utils.tsx
new file mode 100644
index 0000000..993cbb4
--- /dev/null
+++ b/src/pages/user-preference/user-preference-utils.tsx
@@ -0,0 +1,115 @@
+import { message } from 'antd';
+import { userPreferenceProvider } from '../../providers/user-preference-provider';
+import { IProjectGroup, IProject, IUserPreference } from './interface';
+import moment from 'moment-timezone';
+
+export const fetchPreferredProjects = async (): Promise => {
+ try {
+ const response = await userPreferenceProvider.getPreferredProjects();
+ return response.preferred || [];
+ } catch (error) {
+ message.error('Failed to fetch preferred projects');
+ return [];
+ }
+};
+
+export const getAllProjects = (groups: IProjectGroup[] | null | undefined): IProject[] => {
+ if (!groups) return [];
+ return groups.flatMap(group => group.projects);
+};
+
+export const filterProjectsByGroup = (
+ projects: IProject[],
+ groups: IProjectGroup[] | null | undefined,
+ selectedGroup: string
+): IProject[] => {
+ if (selectedGroup === 'all') {
+ return projects;
+ }
+ if (!groups) return [];
+ const group = groups.find(g => g.group_name.toLowerCase() === selectedGroup.toLowerCase());
+ return group ? group.projects : [];
+};
+
+export const getGroupOptions = (groups: IProjectGroup[] | null | undefined) => {
+ if (!groups) return [{ label: 'Show All', value: 'all' }];
+
+ return [
+ { label: 'Show All', value: 'all' },
+ ...groups.map(group => ({
+ label: group.group_name,
+ value: group.group_name.toLowerCase()
+ }))
+ ];
+};
+
+export const saveUserPreferences = async (
+ isDark: boolean,
+ timezone: string
+): Promise => {
+ try {
+ await userPreferenceProvider.updatePreference({
+ isDark,
+ timezone,
+ });
+ message.success('User preferences saved successfully');
+ return true;
+ } catch (error) {
+ message.error('Failed to save user preferences');
+ return false;
+ }
+};
+
+export const savePreferredProjects = async (
+ preferredProjects: IProjectGroup[]
+): Promise => {
+ try {
+ // Transform the data to match the backend API format
+ const payload = {
+ preferred: preferredProjects.map(group => ({
+ group_id: group.group_id || 0, // 0 for new groups
+ group_name: group.group_name,
+ projects: group.projects.map(project => project.uuid)
+ }))
+ };
+
+ await userPreferenceProvider.savePreferredProjects(payload);
+ message.success('Project preferences saved successfully');
+ return true;
+ } catch (error) {
+ message.error('Failed to save project preferences');
+ return false;
+ }
+};
+
+export const deleteProjectGroups = async (groupIds: number[]): Promise => {
+ try {
+ const payload = {
+ preferred: groupIds.map(id => ({ group_id: id }))
+ };
+
+ await userPreferenceProvider.deletePreferredProjects(payload);
+ message.success('Project groups deleted successfully');
+ return true;
+ } catch (error) {
+ message.error('Failed to delete project groups');
+ return false;
+ }
+};
+
+export const fetchUserPreference = async (): Promise => {
+ try {
+ const response = await userPreferenceProvider.getUserPreference();
+ return {
+ isDark: response.IsDark !== undefined ? response.IsDark : false,
+ timezone: response.Timezone || moment.tz.guess(),
+ };
+ } catch (error) {
+ message.error('Failed to fetch user preferences');
+ return null;
+ }
+};
+
+export const getTimezoneOptions = () => {
+ return moment.tz.names().map(tz => ({ label: tz, value: tz }));
+};
\ No newline at end of file
diff --git a/src/pages/user-preference/user-preference.tsx b/src/pages/user-preference/user-preference.tsx
new file mode 100644
index 0000000..74dc399
--- /dev/null
+++ b/src/pages/user-preference/user-preference.tsx
@@ -0,0 +1,294 @@
+import React, { useState, useEffect, useContext } from 'react';
+import { Card, Select, Switch, Button, List, Typography, Space, Pagination, Popconfirm } from 'antd';
+import { DeleteOutlined, CloseOutlined } from '@ant-design/icons';
+import { ColorModeContext } from "../../contexts/color-mode";
+import { IProjectGroup, IProject } from './interface';
+import {
+ fetchPreferredProjects,
+ saveUserPreferences,
+ savePreferredProjects,
+ deleteProjectGroups,
+ getTimezoneOptions,
+ getAllProjects,
+ filterProjectsByGroup,
+ getGroupOptions,
+ fetchUserPreference
+} from './user-preference-utils';
+import moment from 'moment-timezone';
+
+const { Title } = Typography;
+const PAGE_SIZE = 15;
+
+export const UserPreferencePage: React.FC = () => {
+ const { mode, setMode } = useContext(ColorModeContext);
+ const [timezone, setTimezone] = useState(moment.tz.guess());
+ const [originalPreferredProjects, setOriginalPreferredProjects] = useState([]);
+ const [preferredProjects, setPreferredProjects] = useState([]);
+ const [deletedGroupIds, setDeletedGroupIds] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [hasChanges, setHasChanges] = useState(false);
+
+ const [selectedGroup, setSelectedGroup] = useState('all');
+ const [currentPage, setCurrentPage] = useState(1);
+ const [allProjects, setAllProjects] = useState([]);
+
+ useEffect(() => {
+ // Clear any active navigation tab
+ const navLinks = document.querySelectorAll('.ant-menu-item-selected');
+ navLinks.forEach(link => {
+ link.classList.remove('ant-menu-item-selected');
+ });
+
+ const initPreferredProjects = async () => {
+ const projects = await fetchPreferredProjects();
+ setOriginalPreferredProjects(projects);
+ setPreferredProjects(projects);
+ setAllProjects(getAllProjects(projects));
+ };
+
+ const initUserPreference = async () => {
+ const userPref = await fetchUserPreference();
+ if (userPref) {
+ setTimezone(userPref.timezone);
+ }
+ };
+
+ initPreferredProjects();
+ initUserPreference();
+ }, []);
+
+ // Check if there are changes
+ useEffect(() => {
+ const hasProjectChanges = JSON.stringify(originalPreferredProjects) !== JSON.stringify(preferredProjects);
+ const hasDeletedGroups = deletedGroupIds.length > 0;
+ setHasChanges(hasProjectChanges || hasDeletedGroups);
+ }, [originalPreferredProjects, preferredProjects, deletedGroupIds]);
+
+ const handleGroupChange = (value: string) => {
+ setSelectedGroup(value);
+ setCurrentPage(1);
+ };
+
+ const displayedProjects = filterProjectsByGroup(allProjects, preferredProjects, selectedGroup);
+ const startIndex = (currentPage - 1) * PAGE_SIZE;
+ const endIndex = startIndex + PAGE_SIZE;
+ const paginatedProjects = displayedProjects.slice(startIndex, endIndex);
+
+ const getCurrentGroup = () => {
+ if (selectedGroup === 'all') return null;
+ return preferredProjects.find(g => g.group_name.toLowerCase() === selectedGroup.toLowerCase());
+ };
+
+ const handleRemoveProject = (projectUuid: string) => {
+ setPreferredProjects(prevGroups =>
+ prevGroups.map(group => ({
+ ...group,
+ projects: group.projects.filter(project => project.uuid !== projectUuid)
+ })).filter(group => group.projects.length > 0) // Remove empty groups
+ );
+
+ // Update allProjects for immediate UI feedback
+ setAllProjects(prev => prev.filter(project => project.uuid !== projectUuid));
+ };
+
+ const handleDeleteGroup = (groupName: string) => {
+ const groupToDelete = preferredProjects.find(g => g.group_name === groupName);
+ if (!groupToDelete) return;
+
+ // Add to deleted groups if it has an ID (existing group)
+ if (groupToDelete.group_id && groupToDelete.group_id !== 0) {
+ setDeletedGroupIds(prev => [...prev, groupToDelete.group_id!]);
+ }
+
+ // Remove group from preferred projects
+ setPreferredProjects(prev => prev.filter(g => g.group_name !== groupName));
+
+ // Update allProjects by removing all projects from this group
+ setAllProjects(prev => prev.filter(project =>
+ !groupToDelete.projects.some(groupProject => groupProject.uuid === project.uuid)
+ ));
+
+ // Reset selected group if current group was deleted
+ if (selectedGroup.toLowerCase() === groupName.toLowerCase()) {
+ setSelectedGroup('all');
+ }
+ };
+
+ const handleSave = async () => {
+ setLoading(true);
+
+ try {
+ // Save user preferences (theme and timezone)
+ const userPrefSuccess = await saveUserPreferences(mode === 'dark', timezone);
+
+ // Save preferred projects if there are changes
+ if (hasChanges) {
+ let success = true;
+
+ // Delete groups first if any
+ if (deletedGroupIds.length > 0) {
+ success = await deleteProjectGroups(deletedGroupIds);
+ }
+
+ // Save/update preferred projects
+ if (success && preferredProjects.length > 0) {
+ success = await savePreferredProjects(preferredProjects);
+ }
+
+ if (success) {
+ // Update original state and reset changes tracking
+ setOriginalPreferredProjects(preferredProjects);
+ setDeletedGroupIds([]);
+
+ // Update global theme mode
+ if (userPrefSuccess) {
+ window.localStorage.setItem("colorMode", mode);
+ }
+ }
+ } else if (userPrefSuccess) {
+ // Only user preferences changed
+ window.localStorage.setItem("colorMode", mode);
+ }
+ } catch (error) {
+ console.error('Error saving preferences:', error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleReset = () => {
+ setPreferredProjects(originalPreferredProjects);
+ setDeletedGroupIds([]);
+ setAllProjects(getAllProjects(originalPreferredProjects));
+ setSelectedGroup('all');
+ };
+
+ return (
+
+
+
+
Timezone
+
+
+
+
+
Dark mode
+ setMode(mode === "light" ? "dark" : "light")}
+ />
+
+
+
+
+
Group Project
+ {getCurrentGroup() && (
+
handleDeleteGroup(getCurrentGroup()!.group_name)}
+ okText="Yes"
+ cancelText="No"
+ >
+ }
+ title="Delete this group"
+ >
+ Delete Group
+
+
+ )}
+
+
+
+
+
(
+ handleRemoveProject(project.uuid)}
+ okText="Yes"
+ cancelText="No"
+ >
+
+
+ ]}
+ >
+ {project.name}
+
+ )}
+ pagination={false}
+ />
+ {displayedProjects.length > 0 && (
+
+ )}
+
+
+
+
+ {hasChanges && (
+
+ )}
+
+
+ {hasChanges && (
+
+ ⚠️ You have unsaved changes. Click "Save" to apply them.
+
+ )}
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/providers/user-preference-provider.ts b/src/providers/user-preference-provider.ts
new file mode 100644
index 0000000..93d0c09
--- /dev/null
+++ b/src/providers/user-preference-provider.ts
@@ -0,0 +1,43 @@
+import axios from "axios";
+import { IUserPreference } from "../pages/user-preference/interface";
+import { API_URL } from "./testrun-provider";
+
+export const userPreferenceProvider = {
+ updatePreference: async (data: IUserPreference) => {
+ const response = await axios.put(
+ `${API_URL}/user/preference`,
+ data,
+ { withCredentials: true }
+ );
+ return response.data;
+ },
+
+ getPreferredProjects: async () => {
+ const response = await axios.get(`${API_URL}/user/preferred`, {
+ withCredentials: true
+ });
+ return response.data;
+ },
+
+ savePreferredProjects: async (data: any) => {
+ const response = await axios.post(`${API_URL}/user/preferred`, data, {
+ withCredentials: true
+ });
+ return response.data;
+ },
+
+ deletePreferredProjects: async (data: any) => {
+ const response = await axios.delete(`${API_URL}/user/preferred`, {
+ withCredentials: true,
+ data: data
+ });
+ return response.data;
+ },
+
+ getUserPreference: async () => {
+ const response = await axios.get(`${API_URL}/user/preference`, {
+ withCredentials: true
+ });
+ return response.data;
+ },
+};
diff --git a/src/utils/debounce.ts b/src/utils/debounce.ts
new file mode 100644
index 0000000..8530ab8
--- /dev/null
+++ b/src/utils/debounce.ts
@@ -0,0 +1,7 @@
+export const debounce = (fn: Function, ms = 300) => {
+ let timeoutId: ReturnType;
+ return function(...args: any[]) {
+ clearTimeout(timeoutId);
+ timeoutId = setTimeout(() => fn(...args), ms);
+ };
+};
\ No newline at end of file
diff --git a/test/component/user-preference.test.tsx b/test/component/user-preference.test.tsx
new file mode 100644
index 0000000..b34b3f5
--- /dev/null
+++ b/test/component/user-preference.test.tsx
@@ -0,0 +1,269 @@
+import React from "react";
+import '@testing-library/jest-dom';
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import { UserPreferencePage } from "../../src/pages/user-preference";
+import { ColorModeContext } from "../../src/contexts/color-mode";
+import moment from 'moment-timezone';
+
+// Mock moment-timezone to control timezone options
+jest.mock('moment-timezone', () => {
+ const originalModule = jest.requireActual('moment-timezone');
+ return {
+ ...originalModule,
+ tz: {
+ ...originalModule.tz,
+ guess: jest.fn().mockReturnValue('America/Los_Angeles'),
+ names: jest.fn().mockReturnValue(['America/Los_Angeles', 'America/New_York', 'Asia/Tokyo'])
+ }
+ };
+});
+
+// Mock the providers
+jest.mock('../../src/providers/user-preference-provider', () => ({
+ userPreferenceProvider: {
+ updatePreference: jest.fn(),
+ getPreferredProjects: jest.fn(),
+ removePreferredProject: jest.fn(),
+ getUserPreference: jest.fn()
+ }
+}));
+
+// Import the mocked module
+const { userPreferenceProvider } = require('../../src/providers/user-preference-provider');
+
+// Mock antd message component
+jest.mock('antd', () => {
+ const originalModule = jest.requireActual('antd');
+ return {
+ ...originalModule,
+ message: {
+ success: jest.fn(),
+ error: jest.fn()
+ }
+ };
+});
+
+const { message } = require('antd');
+
+const mockSetMode = jest.fn();
+const mockContextValue = {
+ mode: 'light',
+ setMode: mockSetMode
+};
+
+describe('UserPreferencePage Component Tests', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ // Default mock implementations
+ userPreferenceProvider.getUserPreference.mockResolvedValue({
+ isDark: false,
+ timezone: "America/Los_Angeles"
+ });
+
+ userPreferenceProvider.getPreferredProjects.mockResolvedValue({
+ preferred: []
+ });
+
+ userPreferenceProvider.updatePreference.mockResolvedValue({
+ isDark: false,
+ timezone: "America/Los_Angeles"
+ });
+
+ userPreferenceProvider.removePreferredProject.mockResolvedValue({
+ success: true
+ });
+ });
+
+ test('renders user preference page with default values', async () => {
+ render(
+
+
+
+ );
+
+ // Check for main components
+ await waitFor(() => {
+ expect(screen.getByText(/User Preference/i)).toBeInTheDocument();
+ expect(screen.getByText(/Timezone/i)).toBeInTheDocument();
+ expect(screen.getByText(/Dark mode/i)).toBeInTheDocument();
+ expect(screen.getByText(/Save/i)).toBeInTheDocument();
+ });
+
+ // Verify API calls
+ expect(userPreferenceProvider.getUserPreference).toHaveBeenCalledTimes(1);
+ expect(userPreferenceProvider.getPreferredProjects).toHaveBeenCalledTimes(1);
+ });
+
+ test('handles save preferences correctly', async () => {
+ render(
+
+
+
+ );
+
+ // Wait for component to load
+ await waitFor(() => {
+ expect(screen.getByText(/Save/i)).toBeInTheDocument();
+ });
+
+ // Click save button
+ const saveButton = screen.getByText(/Save/i);
+ fireEvent.click(saveButton);
+
+ await waitFor(() => {
+ expect(userPreferenceProvider.updatePreference).toHaveBeenCalledWith({
+ isDark: false,
+ timezone: "America/Los_Angeles"
+ });
+ // Update this to match the actual message in your component
+ expect(message.success).toHaveBeenCalled();
+ });
+ });
+
+ test('displays error when preference update fails', async () => {
+ userPreferenceProvider.updatePreference.mockRejectedValue(
+ new Error('Failed to update preferences')
+ );
+
+ render(
+
+
+
+ );
+
+ // Wait for component to load
+ await waitFor(() => {
+ expect(screen.getByText(/Save/i)).toBeInTheDocument();
+ });
+
+ // Click save button
+ const saveButton = screen.getByText(/Save/i);
+ fireEvent.click(saveButton);
+
+ await waitFor(() => {
+ expect(userPreferenceProvider.updatePreference).toHaveBeenCalled();
+ // Update this to match the actual message in your component
+ expect(message.error).toHaveBeenCalled();
+ });
+ });
+
+ test('loads preferred projects on component mount', async () => {
+ const mockProjects = {
+ preferred: [
+ {
+ group_id: 1,
+ group_name: "Test Group",
+ projects: [
+ { uuid: "proj_001", name: "Test Project 1" },
+ { uuid: "proj_002", name: "Test Project 2" }
+ ]
+ }
+ ]
+ };
+
+ userPreferenceProvider.getPreferredProjects.mockResolvedValue(mockProjects);
+
+ render(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(userPreferenceProvider.getPreferredProjects).toHaveBeenCalled();
+ expect(screen.getByText('Test Project 1')).toBeInTheDocument();
+ // Don't check for group name if it's not visible in the component
+ });
+ });
+
+ test('toggles dark mode when switch is clicked', async () => {
+ render(
+
+
+
+ );
+
+ // Wait for component to load
+ await waitFor(() => {
+ expect(screen.getByText(/Dark mode/i)).toBeInTheDocument();
+ });
+
+ // Find the dark mode switch by a more reliable method
+ const darkModeLabel = screen.getByText(/Dark mode/i);
+ const darkModeSwitch = darkModeLabel.parentElement?.querySelector('button[role="switch"]');
+ expect(darkModeSwitch).toBeInTheDocument();
+
+ // Click the switch if found
+ if (darkModeSwitch) {
+ fireEvent.click(darkModeSwitch);
+
+ // Verify the setMode function was called
+ expect(mockSetMode).toHaveBeenCalledWith('dark');
+ }
+ });
+
+ test('removes project from favorites', async () => {
+ const mockProjects = {
+ preferred: [
+ {
+ group_id: 1,
+ group_name: "Test Group",
+ projects: [
+ { uuid: "proj_001", name: "Test Project 1" }
+ ]
+ }
+ ]
+ };
+
+ userPreferenceProvider.getPreferredProjects.mockResolvedValue(mockProjects);
+ userPreferenceProvider.removePreferredProject.mockResolvedValue({ success: true });
+
+ render(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText('Test Project 1')).toBeInTheDocument();
+ });
+
+ // Find the delete button by a more reliable method
+ const deleteButtons = document.querySelectorAll('button');
+ const deleteButton = Array.from(deleteButtons).find(button =>
+ button.innerHTML.includes('delete') || button.innerHTML.includes('Delete')
+ );
+
+ if (deleteButton) {
+ fireEvent.click(deleteButton);
+
+ // If there's a confirmation dialog, find and click the confirm button
+ const confirmButton = screen.queryByText('Yes');
+ if (confirmButton) {
+ fireEvent.click(confirmButton);
+ }
+
+ await waitFor(() => {
+ expect(userPreferenceProvider.removePreferredProject).toHaveBeenCalled();
+ });
+ }
+ });
+
+ test('handles empty preferred projects gracefully', async () => {
+ userPreferenceProvider.getPreferredProjects.mockResolvedValue({
+ preferred: null
+ });
+
+ render(
+
+
+
+ );
+
+ // Wait for component to load
+ await waitFor(() => {
+ expect(screen.getByText(/User Preference/i)).toBeInTheDocument();
+ });
+ });
+});
\ No newline at end of file