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 ( - +
+ + Fern Logo + {windowWidth > 480 && ( + Fern Reporter + )} + + {!isMobile ? ( + { + const tab = navTabs.find(tab => tab.key === key); + if (tab) navigate(tab.path); + }} + items={navTabs.map(tab => ({ + label: tab.label, + key: tab.key, + }))} + style={{ + borderBottom: 'none', + paddingLeft: '24px', + backgroundColor: token.colorBgElevated, + }} + /> + ) : ( + +
); }; 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 + + + ( + 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