Skip to content

Commit 3edeae5

Browse files
committed
Merge remote-tracking branch 'origin/develop' into alex/626-add-google-analytics
2 parents 11c398d + 487f574 commit 3edeae5

26 files changed

+1245
-5
lines changed

.eslintignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@ components/Button/button.tsx
3535
components/Calendar/calendar.tsx
3636
components/GiftDetailsView/GiftDetailsView.tsx
3737
components/GiftSuggestionCard/GiftSuggestionCard.tsx
38-
components/GroupCard/GroupCard.tsx
3938
components/ImageSelector/ImageSelector.tsx
4039
components/Input/input.tsx
4140
components/InviteCard/InviteCard.tsx

app/globals.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@
1919
--accent-foreground: 240 5.9% 10%;
2020
--destructive: 0 84.2% 60.2%;
2121
--destructive-foreground: 0 0% 98%;
22+
--success: 167 50.9% 10.4%;
23+
--success-foreground: 0 0% 100%;
24+
--warning: 38 73.1% 56.3%;
25+
--warning-foreground: 0 0% 0%;
2226
--border: 240 5.9% 90%;
2327
--input: 240 5.9% 90%;
2428
--ring: 240 10% 3.9%;

components/GroupCard/GroupCard.tsx

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,23 @@
1+
// Copyright (c) Gridiron Survivor.
2+
// Licensed under the MIT License.
3+
4+
import { JSX } from 'react';
15
import { GiftExchangeWithMemberCount } from '@/app/types/giftExchange';
26
import { formatDate } from '@/lib/utils';
37
import { ChevronRight, Users } from 'lucide-react';
48
import Link from 'next/link';
9+
import Image from 'next/image';
510

611
type GroupCardProps = {
712
giftExchange: GiftExchangeWithMemberCount;
813
};
914

10-
export const GroupCardSkeleton = () => {
15+
/**
16+
* GroupCardSkeleton component.
17+
* Displays a loading skeleton placeholder for a GroupCard.
18+
* @returns {JSX.Element} Loader skeleton element.
19+
*/
20+
export const GroupCardSkeleton = (): JSX.Element => {
1121
return (
1222
<div className="h-28 flex items-center p-4 rounded-xl bg-groupCardGreen animate-pulse">
1323
<div className="h-16 w-16 lg:h-20 lg:w-20 rounded-xl bg-gray-600" />
@@ -26,14 +36,22 @@ export const GroupCardSkeleton = () => {
2636
);
2737
};
2838

29-
const GroupCard = ({ giftExchange }: GroupCardProps) => {
39+
/**
40+
* GroupCard component. Renders a styled card that displays
41+
* various information about a gift exchange group.
42+
* @param {GiftExchangeWithMemberCount} giftExchange - A unique gift exchange.
43+
* @returns {JSX.Element} A group card element.
44+
*/
45+
const GroupCard = ({ giftExchange }: GroupCardProps): JSX.Element => {
3046
return (
3147
<Link href={`/gift-exchanges/${giftExchange.gift_exchange_id}`}>
3248
<div className="h-28 flex items-center p-4 rounded-xl bg-groupCardGreen">
33-
<img
49+
<Image
3450
className="h-16 w-16 lg:h-20 lg:w-20 rounded-xl"
3551
src={giftExchange.group_image}
36-
alt={`${giftExchange.name} image`}
52+
height={80}
53+
width={80}
54+
alt=""
3755
/>
3856
<div className="flex flex-col flex-grow justify-center h-full ml-4 gap-2">
3957
<h2 className="font-semibold text-white text-base lg:text-lg">
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// Copyright (c) Gridiron Survivor.
2+
// Licensed under the MIT License.
3+
4+
import { render, screen } from '@testing-library/react';
5+
import { LoadingSpinner } from './LoadingSpinner';
6+
7+
describe('LoadingSpinner', () => {
8+
it('renders without additional classes', () => {
9+
render(<LoadingSpinner />);
10+
expect(screen.getByTestId('loading-spinner')).toBeInTheDocument();
11+
});
12+
13+
it('renders with additional classes when className is passed', () => {
14+
render(<LoadingSpinner className="extra-classname" />);
15+
expect(screen.getByTestId('loading-spinner')).toHaveClass(
16+
'extra-classname',
17+
);
18+
});
19+
});

components/Toast/Toast.enum.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// Copyright (c) Gridiron Survivor.
2+
// Licensed under the MIT License.
3+
4+
export enum ToastVariants {
5+
Default = 'default',
6+
Error = 'error',
7+
Success = 'success',
8+
Warning = 'warning',
9+
}

components/Toast/Toast.stories.tsx

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
// Copyright (c) Gridiron Survivor.
2+
// Licensed under the MIT License.
3+
4+
import type { Meta, StoryObj } from '@storybook/react';
5+
import { Toast } from './Toast';
6+
import { ToastProvider } from '../ToastProvider/ToastProvider';
7+
import { ToastViewport } from '../ToastViewport/ToastViewport';
8+
import { ToastTitle } from '../ToastTitle/ToastTitle';
9+
import { ToastDescription } from '../ToastDescription/ToastDescription';
10+
import { ToastAction } from '../ToastAction/ToastAction';
11+
import { ToastClose } from '../ToastClose/ToastClose';
12+
import { ToastVariants } from './Toast.enum';
13+
import type { JSX } from 'react';
14+
15+
const meta = {
16+
title: 'Components/Toast',
17+
component: Toast,
18+
decorators: [
19+
(Story): JSX.Element => (
20+
<div className="relative h-[200px] w-[500px] rounded-md p-4">
21+
<ToastProvider>
22+
<Story />
23+
<ToastViewport className="absolute inset-0 flex flex-col items-center justify-center w-full" />
24+
</ToastProvider>
25+
</div>
26+
),
27+
],
28+
parameters: {
29+
docs: {
30+
description: {
31+
component: `A customizable Toast Component.`,
32+
},
33+
},
34+
},
35+
tags: ['autodocs'],
36+
argTypes: {
37+
variant: {
38+
description: 'The style of the toast',
39+
control: { type: 'select' },
40+
options: [
41+
'default',
42+
'error',
43+
'warning',
44+
'success',
45+
],
46+
},
47+
duration: {
48+
description: 'Auto-dismiss delay in milliseconds',
49+
control: 'number',
50+
defaultValue: 5000,
51+
},
52+
open: {
53+
description: 'Determines whether toast is visible',
54+
control: { type: 'boolean' },
55+
},
56+
forceMount: {
57+
description: 'Renders as child component if true',
58+
control: { type: 'boolean' },
59+
},
60+
className: {
61+
description: 'Classes for additional styling',
62+
type: 'string',
63+
},
64+
children: {
65+
control: 'text',
66+
description: 'Toast content',
67+
type: 'string',
68+
},
69+
},
70+
} satisfies Meta<typeof Toast>;
71+
72+
export default meta;
73+
type Story = StoryObj<typeof meta>;
74+
75+
export const Default: Story = {
76+
args: {
77+
variant: ToastVariants.Default,
78+
duration: 5000,
79+
defaultOpen: true,
80+
children: (
81+
<>
82+
<div className="flex-grow">
83+
<ToastTitle>Default Toast</ToastTitle>
84+
<ToastDescription>This is a notification.</ToastDescription>
85+
</div>
86+
<ToastClose />
87+
</>
88+
),
89+
},
90+
};
91+
92+
export const Error: Story = {
93+
args: {
94+
variant: ToastVariants.Error,
95+
defaultOpen: true,
96+
children: (
97+
<>
98+
<div className="flex-grow">
99+
<ToastTitle>Error Toast</ToastTitle>
100+
<ToastDescription>Something went wrong.</ToastDescription>
101+
<ToastClose />
102+
</div>
103+
</>
104+
),
105+
},
106+
};
107+
108+
export const Warning: Story = {
109+
args: {
110+
variant: ToastVariants.Warning,
111+
defaultOpen: true,
112+
children: (
113+
<>
114+
<div className="flex-grow">
115+
<ToastTitle>Warning Toast</ToastTitle>
116+
<ToastDescription>Something might need attention.</ToastDescription>
117+
<ToastClose />
118+
</div>
119+
</>
120+
),
121+
},
122+
};
123+
124+
export const Success: Story = {
125+
args: {
126+
variant: ToastVariants.Success,
127+
defaultOpen: true,
128+
children: (
129+
<>
130+
<div className="flex-grow">
131+
<ToastTitle>Success Toast</ToastTitle>
132+
<ToastDescription>Your action was successful.</ToastDescription>
133+
<ToastClose />
134+
</div>
135+
</>
136+
),
137+
},
138+
};
139+
140+
export const WithActionAndClose: Story = {
141+
args: {
142+
variant: ToastVariants.Default,
143+
defaultOpen: true,
144+
children: (
145+
<>
146+
<div className="flex-grow">
147+
<ToastTitle>Action & Close</ToastTitle>
148+
<ToastDescription>An action and close button.</ToastDescription>
149+
</div>
150+
<ToastAction altText="action">Action</ToastAction>
151+
<ToastClose />
152+
</>
153+
),
154+
},
155+
};

components/Toast/Toast.test.tsx

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { Toast, toastVariantStyles } from './Toast';
2+
import React from 'react';
3+
import { render, screen } from '@testing-library/react';
4+
import { ToastProvider } from '../ToastProvider/ToastProvider';
5+
import { ToastViewport } from '../ToastViewport/ToastViewport';
6+
import { ToastVariants } from './Toast.enum';
7+
8+
const renderToast = (props = {}) =>
9+
render(
10+
<ToastProvider>
11+
<Toast {...props} />
12+
<ToastViewport />
13+
</ToastProvider>
14+
)
15+
16+
const testVariants = Object.entries(toastVariantStyles).map(([variantValue, classString]) => {
17+
18+
const propVariant = variantValue === ToastVariants.Default ? undefined : variantValue;
19+
20+
const classesToAssert = classString.split(' ').filter(c => c);
21+
22+
return {
23+
name: variantValue,
24+
variant: propVariant,
25+
classes: classesToAssert,
26+
};
27+
});
28+
29+
describe('Toast', () => {
30+
31+
it('does not render when open is false', () =>{
32+
renderToast({open: false})
33+
34+
const toast = screen.queryByTestId('toast');
35+
expect(toast).not.toBeInTheDocument()
36+
})
37+
it('applies default variant styling', () => {
38+
renderToast()
39+
40+
const toast = screen.getByTestId('toast');
41+
expect(toast).toHaveClass('bg-background', 'text-foreground')
42+
});
43+
44+
it.each(testVariants)('applies $name variant styles', ({ variant, classes }) => {
45+
renderToast({ variant });
46+
const toast = screen.getByTestId('toast');
47+
classes.forEach((classStyle) => expect(toast).toHaveClass(classStyle))
48+
})
49+
50+
it('renders the children content within Toast', () => {
51+
render(
52+
<ToastProvider>
53+
<Toast>children</Toast>
54+
<ToastViewport/>
55+
</ToastProvider>
56+
)
57+
58+
const toast = screen.getByTestId('toast');
59+
expect(toast).toHaveTextContent('children');
60+
})
61+
});

components/Toast/Toast.tsx

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// Copyright (c) Gridiron Survivor.
2+
// Licensed under the MIT License.
3+
4+
'use client';
5+
6+
import * as React from 'react';
7+
import * as ToastPrimitives from '@radix-ui/react-toast';
8+
import { cva, type VariantProps } from 'class-variance-authority';
9+
import { cn } from '@/lib/utils';
10+
import { ToastVariants } from './Toast.enum';
11+
12+
export const toastVariantStyles = {
13+
[ToastVariants.Default]: 'border bg-background text-foreground',
14+
[ToastVariants.Error]:
15+
'destructive group border-destructive bg-destructive text-destructive-foreground',
16+
[ToastVariants.Warning]:
17+
'warning group border-warning bg-warning text-warning-foreground',
18+
[ToastVariants.Success]:
19+
'success group border-success bg-success text-success-foreground',
20+
} as const;
21+
22+
export const toastVariants = cva(
23+
'group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
24+
{
25+
variants: {
26+
variant: toastVariantStyles,
27+
},
28+
defaultVariants: {
29+
variant: ToastVariants.Default,
30+
},
31+
},
32+
);
33+
34+
const Toast = React.forwardRef<
35+
React.ElementRef<typeof ToastPrimitives.Root>,
36+
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
37+
VariantProps<typeof toastVariants>
38+
>(({ className, variant, ...props }, ref) => {
39+
return (
40+
<ToastPrimitives.Root
41+
data-testid="toast"
42+
ref={ref}
43+
className={cn(toastVariants({ variant }), className)}
44+
{...props}
45+
/>
46+
);
47+
});
48+
Toast.displayName = ToastPrimitives.Root.displayName;
49+
50+
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
51+
52+
export { type ToastProps, Toast };
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { ToastAction } from "./ToastAction";
2+
import { render, screen, fireEvent } from '@testing-library/react';
3+
4+
describe('ToastAction', () => {
5+
it('renders the children content within ToastAction', () => {
6+
render(<ToastAction altText="undo">Undo</ToastAction>)
7+
8+
const toastAction = screen.getByTestId('toastAction');
9+
expect(toastAction).toHaveTextContent('Undo');
10+
});
11+
12+
it('calls onClick when clicked', () => {
13+
const handleClick = jest.fn();
14+
render(<ToastAction altText="undo" onClick={handleClick}>Undo</ToastAction>)
15+
16+
fireEvent.click(screen.getByText('Undo'));
17+
expect(handleClick).toHaveBeenCalled()
18+
})
19+
})

0 commit comments

Comments
 (0)