Skip to content
Merged

Beta #99

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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,6 @@ VITE_APP_ENV=
VITE_GA_MEASUREMENT_ID=
VITE_UMAMI_SRC=
VITE_UMAMI_WEBSITE_ID=

# Vite dev server proxy for NotifyComp token requests.
NOTIFY_COMP_TOKEN_ORIGIN=https://www.competitiongroups.com
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,11 @@ yarn test
yarn storybook
yarn build-storybook
```

During `yarn dev`, NotifyComp token requests are proxied to
`https://www.competitiongroups.com`, so production WCA sign-in and NotifyComp
authorization work without running Netlify Functions locally. The deployed
function checks the WCA access token and competition permissions. Set
`NOTIFY_COMP_TOKEN_ORIGIN` in `.env.local` to use another deployed token issuer
or a local Netlify Dev server. Use a token issuer connected to the same WCA and
NotifyComp environments as the frontend, and restart Vite after changing it.
3 changes: 2 additions & 1 deletion jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ const config: Config = {
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
transformIgnorePatterns: ['/node_modules/(?!@uidotdev/usehooks/)'],
transform: {
'^.+\\.(ts|tsx)?$': 'ts-jest',
'^.+\\.[jt]sx?$': 'ts-jest',
},
};

Expand Down
70 changes: 34 additions & 36 deletions src/components/CompetitionListItem/CompetitionListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,42 +31,40 @@ export const CompetitionListItem = ({
new Date(end_date).getTime() + 1000 * 60 * new Date().getTimezoneOffset(),
);

return (
<Link to={`/competitions/${id}`}>
<li
className={classNames(
variant === 'card'
? 'link-card list-none flex flex-row cursor-pointer group hover-transition my-1 type-body'
: 'list-none flex flex-row cursor-pointer group hover-transition type-body w-full px-2 py-2 bg-panel hover:bg-gray-100 dark:hover:bg-gray-700',
{
'opacity-50': endDate < new Date(Date.now() - 1000 * 60 * 60 * 24),
},
)}>
{hasFlag(country_iso2) && (
<div className="flex flex-shrink mr-2 type-title">
{' '}
{getUnicodeFlagIcon(country_iso2)}{' '}
</div>
)}
<div className="flex-1">
<p className="type-heading leading-1 dark:text-white"> {name} </p>{' '}
<p className="type-body-sm text-muted leading-1">
{formatDateRange(start_date, end_date)}
{' '}&#8211;{' '}
{city}
</p>
const Item = variant === 'dropdown' ? 'div' : 'li';
const content = (
<Item
className={classNames(
variant === 'card'
? 'link-card list-none flex flex-row cursor-pointer group hover-transition my-1 type-body'
: 'list-none flex flex-row cursor-pointer group hover-transition type-body w-full px-2 py-2',
{
'opacity-50': endDate < new Date(Date.now() - 1000 * 60 * 60 * 24),
},
)}>
{hasFlag(country_iso2) && (
<div className="flex flex-shrink mr-2 type-title"> {getUnicodeFlagIcon(country_iso2)} </div>
)}
<div className="flex-1">
<p className="type-heading leading-1 dark:text-white"> {name} </p>{' '}
<p className="type-body-sm text-muted leading-1">
{formatDateRange(start_date, end_date)}
{' '}&#8211;{' '}
{city}
</p>
</div>
{isLive && (
<div className="flex items-center flex-shrink px-2 type-title">
<span className="text-green-500 fa fa-tower-broadcast" />
</div>
{isLive && (
<div className="flex items-center flex-shrink px-2 type-title">
<span className="text-green-500 fa fa-tower-broadcast" />
</div>
)}
{isBookmarked && !isLive && (
<div className="flex items-center flex-shrink px-2 type-title">
<span className="text-yellow-500 fa fa-bookmark" />
</div>
)}
</li>
</Link>
)}
{isBookmarked && !isLive && (
<div className="flex items-center flex-shrink px-2 type-title">
<span className="text-yellow-500 fa fa-bookmark" />
</div>
)}
</Item>
);

return variant === 'dropdown' ? content : <Link to={`/competitions/${id}`}>{content}</Link>;
};
133 changes: 133 additions & 0 deletions src/components/CompetitionSelect/CompetitionSelect.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import { fetchSearchCompetition } from '@/lib/api';
import { CompetitionSelect } from './CompetitionSelect';

jest.mock('@/lib/api', () => ({ fetchSearchCompetition: jest.fn() }));
jest.mock('@/providers/UserSettingsProvider', () => ({
useUserSettings: () => ({ effectiveTheme: 'light' }),
}));
jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) }));
const competition = {
id: 'Campulung2026',
name: 'Campulung Moldovenesc Open 2026',
start_date: '2026-10-31',
end_date: '2026-10-31',
country_iso2: 'RO',
city: 'Campulung Moldovenesc',
} as ApiCompetition;
const mockSearch = jest.mocked(fetchSearchCompetition);
beforeEach(() => {
jest.clearAllMocks();
mockSearch.mockResolvedValue({ result: [competition] });
});

it('exposes a named combobox and real options with usable active-descendant IDs', async () => {
render(<CompetitionSelect onSelect={jest.fn()} />, { wrapper: MemoryRouter });
const input = screen.getByRole('combobox', { name: 'common.competitionSelect.placeholder' });
await act(async () => {
await userEvent.type(input, 'Campulung');
});
const option = await screen.findByRole('option', { name: /Campulung Moldovenesc Open 2026/ });
await act(async () => {
await userEvent.keyboard('{ArrowDown}');
});
expect(input).toHaveAttribute('aria-activedescendant', option.id);
expect(option.id).not.toBe('');
expect(option).toHaveAttribute('aria-selected', 'false');
expect(option.querySelector('a')).toBeNull();
});

it.each(['keyboard', 'pointer'])('selects the same competition once by %s', async (method) => {
const onSelect = jest.fn();
render(<CompetitionSelect onSelect={onSelect} />, { wrapper: MemoryRouter });
await act(async () => {
await userEvent.type(screen.getByRole('combobox'), 'Campulung');
});
await screen.findByText(competition.name);
if (method === 'keyboard')
await act(async () => {
await userEvent.keyboard('{ArrowDown}{Enter}');
});
else
await act(async () => {
await userEvent.click(screen.getByText(competition.name));
});
expect(onSelect).toHaveBeenCalledTimes(1);
expect(onSelect).toHaveBeenCalledWith(competition.id);
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
expect(screen.getByRole('combobox')).toHaveFocus();
});

it('dismisses with Escape and can search and select again without accidental navigation', async () => {
const onSelect = jest.fn();
render(<CompetitionSelect onSelect={onSelect} />, { wrapper: MemoryRouter });
const input = screen.getByRole('combobox');
await act(async () => {
await userEvent.type(input, 'Campulung');
});
await screen.findByText(competition.name);
await act(async () => {
await userEvent.keyboard('{Escape}');
});
await act(async () => {
await userEvent.keyboard('{Enter}');
});
expect(onSelect).not.toHaveBeenCalled();
expect(input).toHaveFocus();
await act(async () => {
await userEvent.clear(input);
});
await act(async () => {
await userEvent.type(input, 'Campulung');
});
await screen.findByRole('option');
await act(async () => {
await userEvent.keyboard('{ArrowDown}{Enter}');
});
expect(onSelect).toHaveBeenCalledWith(competition.id);
});

it('ignores a stale response after a newer query or after the search is cleared', async () => {
let resolveOld!: (value: { result: ApiCompetition[] }) => void;
mockSearch.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveOld = resolve;
}),
);
const onSelect = jest.fn();
render(<CompetitionSelect onSelect={onSelect} />, { wrapper: MemoryRouter });
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'Old' } });
await waitFor(() => expect(mockSearch).toHaveBeenCalledWith('Old'));
fireEvent.change(input, { target: { value: 'Campulung' } });
await screen.findByText(competition.name);
await act(async () =>
resolveOld({ result: [{ ...competition, id: 'Old2026', name: 'Old competition' }] }),
);
expect(screen.queryByText('Old competition')).not.toBeInTheDocument();
await act(async () => {
await userEvent.clear(input);
});
expect(screen.queryByRole('option')).not.toBeInTheDocument();
expect(onSelect).not.toHaveBeenCalled();
});

it('cancels an unstarted search when navigation unmounts the control', async () => {
jest.useFakeTimers();
try {
const { unmount } = render(<CompetitionSelect onSelect={jest.fn()} />, {
wrapper: MemoryRouter,
});
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'Campulung' } });
unmount();
await act(async () => {
jest.runAllTimers();
});
expect(mockSearch).not.toHaveBeenCalled();
} finally {
jest.useRealTimers();
}
});
30 changes: 24 additions & 6 deletions src/components/CompetitionSelect/CompetitionSelect.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { useCallback, useRef } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import type { ClassNamesConfig, StylesConfig } from 'react-select';
import {
components,
type ClassNamesConfig,
type OptionProps,
type StylesConfig,
} from 'react-select';
import AsyncSelect from 'react-select/async';
import { OptionProps } from 'react-select/dist/declarations/src';
import { fetchSearchCompetition } from '@/lib/api';
import { useUserSettings } from '@/providers/UserSettingsProvider';
import { CompetitionListItem } from '../CompetitionListItem';
Expand Down Expand Up @@ -139,6 +143,11 @@ export const CompetitionSelect = ({ onSelect, className }: CompetitionSelectProp
className={className}
classNamePrefix="cg-select"
cacheOptions
aria-label={t('common.competitionSelect.placeholder')}
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.id}
blurInputOnSelect={false}
loadingMessage={() => t('common.loading')}
loadOptions={loadOptions}
placeholder={t('common.competitionSelect.placeholder')}
noOptionsMessage={({ inputValue }) =>
Expand All @@ -154,16 +163,25 @@ export const CompetitionSelect = ({ onSelect, className }: CompetitionSelectProp
);
};

export const CompetitionOption = ({ data }: OptionProps<ApiCompetition>) => {
return <CompetitionListItem {...data} isLive={false} variant="dropdown" />;
};
export const CompetitionOption = (props: OptionProps<ApiCompetition, false>) => (
<components.Option {...props}>
<CompetitionListItem {...props.data} isLive={false} variant="dropdown" />
</components.Option>
);

function useDebounced<Input, Output>(
fn: (arg: Input) => Promise<Output>,
delay: number,
): (arg: Input) => Promise<Output> {
const timeout = useRef<ReturnType<typeof setTimeout> | null>(null);

useEffect(
() => () => {
if (timeout.current) clearTimeout(timeout.current);
},
[],
);

return useCallback(
(arg: Input) => {
if (timeout.current) {
Expand Down
9 changes: 8 additions & 1 deletion src/components/Container/Container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,18 @@ import { PropsWithChildren } from 'react';
interface ContainerProps extends PropsWithChildren {
className?: string;
fullWidth?: boolean;
scrollRestorationId?: string;
}

export const Container = ({ children, className, fullWidth = false }: ContainerProps) => {
export const Container = ({
children,
className,
fullWidth = false,
scrollRestorationId,
}: ContainerProps) => {
return (
<div
data-scroll-restoration={scrollRestorationId}
className={classNames(
'flex flex-col w-full h-max',
{
Expand Down
Loading
Loading