Skip to content
Open
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
2 changes: 1 addition & 1 deletion app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,8 @@ const RootLayout = ({
<UserFeedbackButton />
{children}
</SnowOverlayProvider>
<Toaster />
</AuthContextProvider>
<Toaster />
</body>
</html>
);
Expand Down
74 changes: 61 additions & 13 deletions components/Toaster/Toaster.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,38 @@ import Toaster from './Toaster';
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { useToast } from '@/hooks/use-toast';
import { useAuthContext } from '@/context/AuthContextProvider';
import useExchangeGroups from '@/hooks/useExchangeGroups';

jest.mock('@/context/AuthContextProvider');

const mockGroupMember = {
id: 'test-member-1',
user_id: 'test-member-1',
member: { avatar: 'https://example.com/mock-avatar.png' },
};

const mockMemberSession = {
session: {
user: mockGroupMember,
},
};

const mockGroups = [
{
gift_exchange_id: 'test-group-1',
owner_id: 'test-member-1',
},
{
gift_exchange_id: 'test-group-2',
owner_id: 'test-member-2',
},
]
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Avoid automated semicolon insertion (90% of all statements in the enclosing script have an explicit semicolon).

Suggested change
]
];

Copilot uses AI. Check for mistakes.

jest.mock('@/hooks/useExchangeGroups', () => ({
__esModule: true,
default: jest.fn(),
}));

jest.mock('@/hooks/use-toast', () => ({
useToast: jest.fn(),
Expand All @@ -12,18 +44,22 @@ const variantTestCases = {
default: {
title: 'Info!',
description: 'Info message',
group: 'test-group-1',
},
error: {
title: 'Error!',
description: 'This is an error message',
group: 'test-group-1',
},
warning: {
title: 'Warning',
description: 'This is a warning message',
group: 'test-group-2',
},
success: {
title: 'Success!',
description: 'This is a success message',
group: 'test-group-2',
},
};

Expand All @@ -36,43 +72,55 @@ const setupToastTest = (key: string, value: any) => {
title: value.title,
description: value.description,
variant: key,
group: value.group,
onOpenChange: mockDismiss,
},
],
toast: jest.fn(),
dismiss: mockDismiss,
});
render(<Toaster />);
(useAuthContext as jest.Mock).mockReturnValue(mockMemberSession);
(useExchangeGroups as jest.Mock).mockReturnValue(mockGroups);
render(
<Toaster />
);
};

describe('ToastNotification', () => {
it.each(Object.entries(variantTestCases))(
'should render the %s toast correctly',
'should render the %s toast correctly if the member is the group owner',
(key, value) => {
setupToastTest(key, value);
expect(screen.getByText(value.title)).toBeInTheDocument();
expect(screen.getByText(value.description)).toBeInTheDocument();
if (value.group === 'test-group-1') {
expect(screen.getByText(value.title)).toBeInTheDocument();
expect(screen.getByText(value.description)).toBeInTheDocument();
}
else if (value.group === 'test-group-2') {
expect(screen.queryByText(value.title)).not.toBeInTheDocument();
expect(screen.queryByText(value.description)).not.toBeInTheDocument();
}
},
);

it.each(Object.entries(variantTestCases))(
'should render the dismiss button for the %s variant',
'should render the dismiss button for the %s variant if the member is the group owner',
async (key, value) => {
setupToastTest(key, value);

const dismissButton = screen.queryByRole('button', { name: 'Close' });
expect(dismissButton).toBeInTheDocument();
if (value.group === 'test-group-1') {
const dismissButton = screen.queryByRole('button', { name: 'Close' });
expect(dismissButton).toBeInTheDocument();
}
},
);
it.each(Object.entries(variantTestCases))(
'should dismiss the %s toast when the dismiss button is clicked',
async (key, value) => {
setupToastTest(key, value);

const dismissButton = screen.getByRole('button', { name: 'Close' });
fireEvent.click(dismissButton);

expect(mockDismiss).toHaveBeenCalled();
if (value.group === 'test-group-1') {
const dismissButton = screen.getByRole('button', { name: 'Close' });
fireEvent.click(dismissButton);
expect(mockDismiss).toHaveBeenCalled();
}
},
);
});
12 changes: 11 additions & 1 deletion components/Toaster/Toaster.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,27 @@ import { ToastClose } from "../ToastClose/ToastClose"
import { ToastViewport } from "../ToastViewport/ToastViewport"
import { ToastProvider } from "../ToastProvider/ToastProvider"
import { JSX } from "react"
import { useAuthContext } from '@/context/AuthContextProvider';
import useExchangeGroups from "@/hooks/useExchangeGroups";

/**
* Renders and manages display of toast notifications.
* @returns {JSX.Element} The rendered toast notification.
*/
const Toaster = (): JSX.Element => {
const { toasts } = useToast()
const { session } = useAuthContext()
const userExchangeGroups = useExchangeGroups()

const filteredToasts = toasts.filter((toast) => {
return userExchangeGroups.some((group) => {
return (group.gift_exchange_id === toast.group && group.owner_id === session?.user.id)
})
Comment on lines +27 to +29
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The filtering logic will hide all toasts that don't have a group property. This breaks existing toasts like TOASTS.badLinkToast, TOASTS.expiredLinkToast, and TOASTS.errorToast which are used in the codebase but don't include a group field.

Consider updating the filter to show toasts without a group property, or only filter toasts that explicitly have a group property:

const filteredToasts = toasts.filter((toast) => {
  // Show toasts without a group (global toasts)
  if (!toast.group) return true;
  
  // For toasts with a group, only show if user is the owner
  return userExchangeGroups.some((group) => {
    return (group.gift_exchange_id === toast.group && group.owner_id === session?.user.id)
  })
})
Suggested change
return userExchangeGroups.some((group) => {
return (group.gift_exchange_id === toast.group && group.owner_id === session?.user.id)
})
// Show toasts without a group (global toasts)
if (!toast.group) return true;
// For toasts with a group, only show if user is the owner
return userExchangeGroups.some((group) => (
group.gift_exchange_id === toast.group && group.owner_id === session?.user.id
));

Copilot uses AI. Check for mistakes.
})
Comment on lines +26 to +30
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The filtering logic runs on every render of the Toaster component. Since userExchangeGroups is fetched asynchronously and could change, consider memoizing the filtered result to avoid unnecessary recomputations:

const filteredToasts = useMemo(() => {
  return toasts.filter((toast) => {
    // Show toasts without a group (global toasts)
    if (!toast.group) return true;
    
    // For toasts with a group, only show if user is the owner
    return userExchangeGroups.some((group) => {
      return (group.gift_exchange_id === toast.group && group.owner_id === session?.user.id)
    })
  })
}, [toasts, userExchangeGroups, session?.user.id])

Copilot uses AI. Check for mistakes.

return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
{filteredToasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
Expand Down
45 changes: 45 additions & 0 deletions hooks/useExchangeGroups.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Copyright (c) Gridiron Survivor.
// Licensed under the MIT License.

import { useState, useEffect } from 'react';
import { GiftExchangeWithMemberCount } from '../app/types/giftExchange';

/**
* A React hook that manages the current users exchange groups.
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JSDoc comment has a grammatical error. Change "manages the current users exchange groups" to "manages the current user's exchange groups" (add apostrophe for possessive).

Suggested change
* A React hook that manages the current users exchange groups.
* A React hook that manages the current user's exchange groups.

Copilot uses AI. Check for mistakes.
* @returns {array} An array containing the current list of user gift exchange groups.
*/

const useExchangeGroups = (): Array<GiftExchangeWithMemberCount> => {
const [giftExchanges, setGiftExchanges] = useState<
GiftExchangeWithMemberCount[]
>([]);

async function fetchGiftExchanges() {
try {
const response = await fetch(`/api/gift-exchanges`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});

if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}

const data = await response.json();
setGiftExchanges(data);

} catch (error) {
console.error('Failed to fetch gift exchanges:', error);
}
Comment on lines +33 to +35
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The error handling doesn't provide any feedback to the user when the fetch fails. The hook silently fails and returns an empty array, which could be confusing. Consider:

  1. Adding a loading state to distinguish between "no data" and "loading"
  2. Adding an error state to handle fetch failures
  3. Or at minimum, logging more context about the error

Example:

const useExchangeGroups = () => {
  const [giftExchanges, setGiftExchanges] = useState<GiftExchangeWithMemberCount[]>([]);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  async function fetchGiftExchanges() {
    try {
      setIsLoading(true);
      const response = await fetch(`/api/gift-exchanges`, {
        method: 'GET',
        headers: {
          'Content-Type': 'application/json',
        },
      });

      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }

      const data = await response.json();
      setGiftExchanges(data);
      setError(null);
    } catch (error) {
      console.error('Failed to fetch gift exchanges:', error);
      setError(error instanceof Error ? error : new Error('Unknown error'));
    } finally {
      setIsLoading(false);
    }
  }

  // ...

  return { giftExchanges, isLoading, error };
};

Copilot uses AI. Check for mistakes.
};
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove the unnecessary semicolon after the closing brace. Function declarations don't require a semicolon.

Suggested change
};
}

Copilot uses AI. Check for mistakes.

useEffect(() => {
fetchGiftExchanges();
Comment on lines +17 to +39
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The useExchangeGroups hook may cause race conditions when the component unmounts before the fetch completes. Consider adding cleanup logic to prevent state updates on unmounted components:

useEffect(() => {
  let isMounted = true;
  
  async function fetchGiftExchanges() {
    try {
      const response = await fetch(`/api/gift-exchanges`, {
        method: 'GET',
        headers: {
          'Content-Type': 'application/json',
        },
      });

      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }

      const data = await response.json();
      if (isMounted) {
        setGiftExchanges(data);
      }
    } catch (error) {
      console.error('Failed to fetch gift exchanges:', error);
    }
  }
  
  fetchGiftExchanges();
  
  return () => {
    isMounted = false;
  };
}, []);
Suggested change
async function fetchGiftExchanges() {
try {
const response = await fetch(`/api/gift-exchanges`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
setGiftExchanges(data);
} catch (error) {
console.error('Failed to fetch gift exchanges:', error);
}
};
useEffect(() => {
fetchGiftExchanges();
useEffect(() => {
let isMounted = true;
async function fetchGiftExchanges() {
try {
const response = await fetch(`/api/gift-exchanges`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
if (isMounted) {
setGiftExchanges(data);
}
} catch (error) {
console.error('Failed to fetch gift exchanges:', error);
}
}
fetchGiftExchanges();
return () => {
isMounted = false;
};

Copilot uses AI. Check for mistakes.
}, []);

return giftExchanges;
};

export default useExchangeGroups;