Skip to content
Merged
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 src/components/ShareModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ export function ShareModal({
if (!isOpen) return null;

return (
<Modal isOpen={isOpen} onClose={onClose} title={title} className="max-w-md">
<Modal isOpen={isOpen} onClose={onClose} title={title} size="md">
<div className="space-y-6">
{/* Description */}
{description && <p className="text-sm text-gray-600 dark:text-gray-400">{description}</p>}
Expand Down
16 changes: 14 additions & 2 deletions src/components/ui/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,23 @@ import { useEffect, useId } from 'react';
import { X } from 'lucide-react';
import { useFocusTrap, useScreenReaderAnnouncement } from '@/hooks/useAccessibility';

export type ModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'full';

const SIZE_CLASSES: Record<ModalSize, string> = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl',
full: 'max-w-full',
};

export interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
/** Controls the maximum width of the modal panel */
size?: ModalSize;
/** Additional class names for the inner panel */
className?: string;
}
Expand All @@ -17,7 +29,7 @@ export interface ModalProps {
* Accessible modal dialog with focus trap, Escape-to-close, and screen reader announcements.
* Uses the existing `useFocusTrap` hook from `useAccessibility`.
*/
export function Modal({ isOpen, onClose, title, children, className = '' }: ModalProps) {
export function Modal({ isOpen, onClose, title, children, size = 'md', className = '' }: ModalProps) {
const titleId = useId();
const containerRef = useFocusTrap(isOpen);
const announce = useScreenReaderAnnouncement();
Expand Down Expand Up @@ -61,7 +73,7 @@ export function Modal({ isOpen, onClose, title, children, className = '' }: Moda
className="fixed inset-0 z-50 flex items-center justify-center p-4"
>
<div
className={`relative w-full max-w-md max-h-[90vh] overflow-y-auto rounded-lg bg-white shadow-xl dark:bg-gray-900 ${className}`}
className={`relative w-full ${SIZE_CLASSES[size]} max-h-[90vh] overflow-y-auto rounded-lg bg-white shadow-xl dark:bg-gray-900 ${className}`}
>
{/* Header */}
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4 dark:border-gray-700">
Expand Down
82 changes: 82 additions & 0 deletions src/components/ui/__tests__/Modal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { vi } from 'vitest';
import { Modal, ModalSize } from '../Modal';

vi.mock('@/hooks/useAccessibility', () => ({
useFocusTrap: () => ({ current: null }),
useScreenReaderAnnouncement: () => vi.fn(),
}));

const defaultProps = {
isOpen: true,
onClose: vi.fn(),
title: 'Test Modal',
children: <p>Content</p>,
};

function getPanel() {
// The inner panel is the div containing the header and content
return screen.getByRole('dialog').querySelector('div');
}

describe('Modal', () => {
it('renders nothing when closed', () => {
render(<Modal {...defaultProps} isOpen={false} />);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});

it('renders title and children when open', () => {
render(<Modal {...defaultProps} />);
expect(screen.getByText('Test Modal')).toBeInTheDocument();
expect(screen.getByText('Content')).toBeInTheDocument();
});

it('calls onClose when backdrop is clicked', () => {
const onClose = vi.fn();
render(<Modal {...defaultProps} onClose={onClose} />);
fireEvent.click(screen.getByRole('dialog').previousElementSibling!);
expect(onClose).toHaveBeenCalledTimes(1);
});

it('calls onClose when Escape is pressed', () => {
const onClose = vi.fn();
render(<Modal {...defaultProps} onClose={onClose} />);
fireEvent.keyDown(document, { key: 'Escape' });
expect(onClose).toHaveBeenCalledTimes(1);
});

it('calls onClose when close button is clicked', () => {
const onClose = vi.fn();
render(<Modal {...defaultProps} onClose={onClose} />);
fireEvent.click(screen.getByLabelText('Close dialog'));
expect(onClose).toHaveBeenCalledTimes(1);
});

describe('size classes', () => {
const sizes: Array<[ModalSize, string]> = [
['sm', 'max-w-sm'],
['md', 'max-w-md'],
['lg', 'max-w-lg'],
['xl', 'max-w-xl'],
['full', 'max-w-full'],
];

it.each(sizes)('applies %s → %s', (size, expectedClass) => {
render(<Modal {...defaultProps} size={size} />);
expect(getPanel()).toHaveClass(expectedClass);
});

it('defaults to md (max-w-md) when size is omitted', () => {
render(<Modal {...defaultProps} />);
expect(getPanel()).toHaveClass('max-w-md');
});
});

it('merges extra className with size class', () => {
render(<Modal {...defaultProps} size="lg" className="my-custom-class" />);
const panel = getPanel();
expect(panel).toHaveClass('max-w-lg');
expect(panel).toHaveClass('my-custom-class');
});
});
138 changes: 138 additions & 0 deletions src/lib/graphql/subscriptions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { vi, describe, it, expect, beforeEach } from 'vitest';

// ── Mock heavy Apollo/graphql-ws deps before importing the module under test ──
vi.mock('@apollo/client/link/subscriptions', () => ({
GraphQLWsLink: vi.fn().mockImplementation(() => ({ request: vi.fn() })),
}));

vi.mock('graphql-ws', () => ({
createClient: vi.fn().mockReturnValue({}),
}));

vi.mock('@apollo/client', () => {
const HttpLink = vi.fn().mockImplementation(() => ({ request: vi.fn() }));
const ApolloLink = { from: vi.fn((links) => links[0]) };
const split = vi.fn((test, ws, http) => ({ _ws: ws, _http: http, _split: true }));
const ApolloClient = vi.fn().mockImplementation((opts) => ({ link: opts.link }));
const InMemoryCache = vi.fn().mockImplementation(() => ({}));
return { HttpLink, ApolloLink, split, ApolloClient, InMemoryCache };
});

vi.mock('@apollo/client/utilities', () => ({
getMainDefinition: vi.fn(),
}));

import { createClient as createWSClient } from 'graphql-ws';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { split, HttpLink } from '@apollo/client';
import { flagStore } from '@/lib/feature-flags';
import { isFeatureEnabled, createSubscriptionClient, SubscriptionConfig } from './subscriptions';

const BASE_CONFIG: SubscriptionConfig = {
subscriptionUrl: 'ws://localhost/graphql',
httpUrl: 'http://localhost/graphql',
};

function seedFlag(id: string, enabled: boolean) {
flagStore.set(id, {
id,
name: id,
description: '',
enabled,
strategy: 'all',
percentage: 100,
rules: [],
tags: [],
createdAt: '',
updatedAt: '',
createdBy: 'test',
});
}

beforeEach(() => {
flagStore.clear();
vi.clearAllMocks();
});

// ── isFeatureEnabled ──────────────────────────────────────────────────────────

describe('isFeatureEnabled', () => {
it('returns false when flag does not exist', () => {
expect(isFeatureEnabled('flag_missing')).toBe(false);
});

it('returns false when flag is disabled', () => {
seedFlag('flag_off', false);
expect(isFeatureEnabled('flag_off')).toBe(false);
});

it('returns true when flag is enabled with strategy=all', () => {
seedFlag('flag_on', true);
expect(isFeatureEnabled('flag_on')).toBe(true);
});
});

// ── createSubscriptionClient — feature gate ───────────────────────────────────

describe('createSubscriptionClient', () => {
it('builds WS+split link when no featureGate is provided', () => {
createSubscriptionClient(BASE_CONFIG);
expect(createWSClient).toHaveBeenCalledTimes(1);
expect(GraphQLWsLink).toHaveBeenCalledTimes(1);
expect(split).toHaveBeenCalledTimes(1);
});

it('builds WS+split link when featureGate flag is enabled', () => {
seedFlag('flag_subs', true);
createSubscriptionClient({ ...BASE_CONFIG, featureGate: { flagId: 'flag_subs' } });
expect(createWSClient).toHaveBeenCalledTimes(1);
expect(split).toHaveBeenCalledTimes(1);
});

it('falls back to HTTP-only link when featureGate flag is disabled', () => {
seedFlag('flag_subs', false);
createSubscriptionClient({ ...BASE_CONFIG, featureGate: { flagId: 'flag_subs' } });
expect(createWSClient).not.toHaveBeenCalled();
expect(GraphQLWsLink).not.toHaveBeenCalled();
expect(split).not.toHaveBeenCalled();
expect(HttpLink).toHaveBeenCalledTimes(1);
});

it('falls back to HTTP-only link when featureGate flag does not exist', () => {
createSubscriptionClient({ ...BASE_CONFIG, featureGate: { flagId: 'flag_nonexistent' } });
expect(createWSClient).not.toHaveBeenCalled();
expect(split).not.toHaveBeenCalled();
});

it('passes featureGate context to flag evaluation', () => {
flagStore.set('flag_targeting', {
id: 'flag_targeting',
name: 'targeting',
description: '',
enabled: true,
strategy: 'targeting',
percentage: 0,
rules: [{ attribute: 'plan', operator: 'equals', value: 'pro' }],
tags: [],
createdAt: '',
updatedAt: '',
createdBy: 'test',
});

// pro user → WS enabled
createSubscriptionClient({
...BASE_CONFIG,
featureGate: { flagId: 'flag_targeting', context: { plan: 'pro' } },
});
expect(createWSClient).toHaveBeenCalledTimes(1);

vi.clearAllMocks();

// free user → HTTP only
createSubscriptionClient({
...BASE_CONFIG,
featureGate: { flagId: 'flag_targeting', context: { plan: 'free' } },
});
expect(createWSClient).not.toHaveBeenCalled();
});
});
Loading
Loading