diff --git a/app/javascript/EventsApp/EventCatalog/EventList/EventListCategoryDropdown.tsx b/app/javascript/EventsApp/EventCatalog/EventList/EventListCategoryDropdown.tsx index 1cece55ad7..f7d52a7ae0 100644 --- a/app/javascript/EventsApp/EventCatalog/EventList/EventListCategoryDropdown.tsx +++ b/app/javascript/EventsApp/EventCatalog/EventList/EventListCategoryDropdown.tsx @@ -29,7 +29,9 @@ function EventListCategoryDropdown({ onChange: onChangeProp, }: EventListCategoryDropdownProps): React.JSX.Element { const [interacted, setInteracted] = useState(false); - const currentCategories = eventCategories.filter((category) => (value || []).includes(category.id)); + // (values decoded from the URL are numbers, so compare them as strings) + const selectedCategoryIds = (value || []).map((id) => id.toString()); + const currentCategories = eventCategories.filter((category) => selectedCategoryIds.includes(category.id)); const { t } = useTranslation(); let categoryDescription = t('events.categoryDropdown.allCategoriesLabel'); diff --git a/app/javascript/EventsApp/EventCatalog/EventList/EventListEvents.tsx b/app/javascript/EventsApp/EventCatalog/EventList/EventListEvents.tsx index c0fb11e25a..2579657f2a 100644 --- a/app/javascript/EventsApp/EventCatalog/EventList/EventListEvents.tsx +++ b/app/javascript/EventsApp/EventCatalog/EventList/EventListEvents.tsx @@ -53,11 +53,10 @@ function EventListEvents({ const conventionDay = conventionDayTimespans.find((timespan) => timespan.includesTime(DateTime.fromISO(runs[0].starts_at, { zone: timezoneName })), ); - if (conventionDay && (previousConventionDay == null || !previousConventionDay.isSame(conventionDay))) { - previousConventionDay = conventionDay; - } + // a heading goes above the first event that starts on each convention day if (conventionDay && (previousConventionDay == null || !previousConventionDay.isSame(conventionDay))) { eventIds.set(event.id, conventionDay.start); + previousConventionDay = conventionDay; } } } diff --git a/app/javascript/EventsApp/EventCatalog/EventList/index.tsx b/app/javascript/EventsApp/EventCatalog/EventList/index.tsx index 2622b86530..23f8a86f04 100644 --- a/app/javascript/EventsApp/EventCatalog/EventList/index.tsx +++ b/app/javascript/EventsApp/EventCatalog/EventList/index.tsx @@ -237,7 +237,8 @@ function EventList(): React.JSX.Element { {data && ( <> ({ + ...event, + event_category: eventCategory, +}); + +describe('EventCard', () => { + let user: ReturnType; + + beforeEach(() => { + user = userEvent.setup(); + }); + + const renderCard = ( + event: CatalogEventEntry, + { + eventCategory = category, + canReadSchedule = true, + sortBy, + myProfile, + apolloMocks, + }: { + eventCategory?: CatalogCategory; + canReadSchedule?: boolean; + sortBy?: { id: string; desc: boolean }[]; + myProfile?: boolean; + apolloMocks?: MockLink.MockedResponse[]; + } = {}, + ) => + render( + , + { + appRootContextValue: { + ...appRootContextValue, + ...(myProfile ? { myProfile: { __typename: 'UserConProfile', id: '2', name: 'Me' } as never } : {}), + }, + apolloMocks, + }, + ); + + it('shows the title as a link to the event, the category and the blurb', async () => { + const { getByRole, getByText } = await renderCard(buildCatalogEvent({ id: '9', title: 'Big Game' })); + + expect(getByRole('link', { name: 'Big Game' }).getAttribute('href')).toBe('/events/9-big-game'); + expect(getByText('Tabletop RPG')).toBeTruthy(); + expect(getByText('A fine game')).toBeTruthy(); + }); + + describe('when it runs', () => { + it('says it is unscheduled when there are no runs', async () => { + const { getByText } = await renderCard(buildCatalogEvent()); + + expect(getByText('Unscheduled')).toBeTruthy(); + }); + + it('does not show run times to someone who cannot read the schedule', async () => { + const { queryByText } = await renderCard( + buildCatalogEvent({ runs: [buildCatalogRun('1', '2026-06-05T18:00:00Z')] }), + { canReadSchedule: false }, + ); + + expect(queryByText('Unscheduled')).toBeNull(); + expect(document.body.textContent).not.toMatch(/6:00/); + }); + + it('lists up to four runs in time order', async () => { + const { container } = await renderCard( + buildCatalogEvent({ + runs: [buildCatalogRun('2', '2026-06-06T18:00:00Z'), buildCatalogRun('1', '2026-06-05T14:00:00Z')], + }), + ); + + const lead = container.querySelector('.lead') as HTMLElement; + const text = lead.textContent ?? ''; + expect(text.indexOf('2:00pm')).toBeGreaterThan(-1); + expect(text.indexOf('2:00pm')).toBeLessThan(text.indexOf('6:00pm')); + expect(text).toMatch(/ and /); + }); + + it('gives only the time for a second run on the same day', async () => { + const { container } = await renderCard( + buildCatalogEvent({ + runs: [buildCatalogRun('1', '2026-06-05T14:00:00Z'), buildCatalogRun('2', '2026-06-05T18:00:00Z')], + }), + ); + + const text = (container.querySelector('.lead') as HTMLElement).textContent ?? ''; + // (the date shows once per size variant, so a second run's date would add more) + expect(text.match(/June 5, 2026/g)).toHaveLength(1); + expect(text.match(/Jun 5, 2026/g)).toHaveLength(1); + expect(text).toMatch(/and 6:00pm/); + }); + + it('summarises more than four runs by count and first start', async () => { + const runs = [1, 2, 3, 4, 5].map((n) => buildCatalogRun(String(n), `2026-06-0${n}T14:00:00Z`)); + const { container } = await renderCard(buildCatalogEvent({ runs })); + + expect((container.querySelector('.lead') as HTMLElement).textContent).toMatch(/^5 runs starting /); + }); + }); + + describe('the team', () => { + it('lists displayed team members in surname order, with the category’s name for the role', async () => { + const { getByText } = await renderCard( + buildCatalogEvent({ + team_members: [ + buildCatalogTeamMember('1', 'Zed Zimmer'), + buildCatalogTeamMember('2', 'Amy Adams'), + buildCatalogTeamMember('3', 'Hidden Person', { display: false }), + ], + }), + ); + + expect(getByText('GMs:')).toBeTruthy(); + const content = getByText('GMs:').parentElement?.textContent ?? ''; + expect(content).toBe('GMs: Amy Adams, Zed Zimmer'); + }); + + it('uses the singular for one team member', async () => { + const { getByText } = await renderCard( + buildCatalogEvent({ team_members: [buildCatalogTeamMember('1', 'Amy Adams')] }), + ); + + expect(getByText('GM:')).toBeTruthy(); + }); + + it('shows avatars for team members who have them enabled', async () => { + const { container } = await renderCard( + buildCatalogEvent({ team_members: [buildCatalogTeamMember('1', 'Amy Adams', { gravatar: true })] }), + ); + + expect(container.querySelector('img')).toBeTruthy(); + }); + + it('shows no team line without displayed team members', async () => { + const { queryByText } = await renderCard(buildCatalogEvent()); + + expect(queryByText(/GMs?:/)).toBeNull(); + }); + }); + + describe('the author', () => { + const team = [buildCatalogTeamMember('1', 'Amy Adams'), buildCatalogTeamMember('2', 'Bob Brown')]; + + it('is shown when it is not just the team', async () => { + const { getByText } = await renderCard( + buildCatalogEvent({ team_members: team, form_response_attrs: { author: 'Someone Else' } }), + ); + + expect(getByText('Someone Else')).toBeTruthy(); + }); + + it('is left out when it is exactly the team', async () => { + const { queryByText } = await renderCard( + buildCatalogEvent({ team_members: team, form_response_attrs: { author: 'Amy Adams and Bob Brown' } }), + ); + + expect(queryByText(/Author/)).toBeNull(); + }); + + it('is shown when it names the team plus others', async () => { + const { getByText } = await renderCard( + buildCatalogEvent({ + team_members: team, + form_response_attrs: { author: 'Amy Adams and Bob Brown and Carol Clark' }, + }), + ); + + expect(getByText(/Carol Clark/)).toBeTruthy(); + }); + + it('pluralizes the label for several authors', async () => { + const { getByText } = await renderCard( + buildCatalogEvent({ form_response_attrs: { author: 'Carol Clark, Dan Davis' } }), + ); + + expect(getByText(/Authors:/)).toBeTruthy(); + }); + }); + + it('shows the organization', async () => { + const { getByText } = await renderCard(buildCatalogEvent({ form_response_attrs: { organization: 'Acme Games' } })); + + expect(getByText('Acme Games')).toBeTruthy(); + }); + + describe('items exposed in the catalog', () => { + const exposed = buildFreeTextItem({ identifier: 'system', caption: 'System' }); + const hidden = buildFreeTextItem({ identifier: 'secret', caption: 'Secret' }); + const richCategory = () => + buildCatalogCategory({}, [ + buildCatalogFormItem(exposed, 'Game system'), + buildCatalogFormItem({ ...hidden, id: 'item-secret' }, 'Secret thing', false), + ]); + + it('are shown with their public description and value', async () => { + const { getByText } = await renderCard( + buildCatalogEvent({ form_response_attrs: { system: 'D&D', secret: 'hush' } }), + { eventCategory: richCategory() }, + ); + + expect(getByText('Game system:')).toBeTruthy(); + expect(getByText(/D&D/)).toBeTruthy(); + }); + + it('are left out when not exposed, or when there is no value', async () => { + const { queryByText } = await renderCard(buildCatalogEvent({ form_response_attrs: { secret: 'hush' } }), { + eventCategory: richCategory(), + }); + + expect(queryByText('Game system:')).toBeNull(); + expect(queryByText('Secret thing:')).toBeNull(); + expect(queryByText(/hush/)).toBeNull(); + }); + }); + + describe('when it was added', () => { + it('is shown when sorting by order added', async () => { + const { getByText } = await renderCard(buildCatalogEvent({ created_at: '2026-01-02T12:00:00Z' }), { + sortBy: [{ id: 'created_at', desc: true }], + }); + + expect(getByText(/Added .*Jan/)).toBeTruthy(); + }); + + it('is not shown otherwise', async () => { + const { queryByText } = await renderCard(buildCatalogEvent(), { sortBy: [{ id: 'title', desc: false }] }); + + expect(queryByText(/Added/)).toBeNull(); + }); + }); + + describe('rating', () => { + it('is offered only to people with a profile', async () => { + const without = await renderCard(buildCatalogEvent()); + expect(without.queryByRole('button', { name: /favorite|like|rating/i })).toBeNull(); + without.unmount(); + + const withProfile = await renderCard(buildCatalogEvent(), { myProfile: true }); + expect(withProfile.getAllByRole('button').length).toBeGreaterThan(0); + }); + + it('sends the chosen rating for the event', async () => { + const sent = vi.fn(); + const { getAllByRole } = await renderCard(buildCatalogEvent({ id: '9' }), { + myProfile: true, + apolloMocks: [ + { + request: { + query: RateEventDocument, + variables: (variables: unknown) => { + sent(variables); + return true; + }, + }, + result: { + data: { + rateEvent: { __typename: 'RateEventPayload', event: { __typename: 'Event', id: '9', my_rating: 1 } }, + }, + }, + }, + ], + }); + + // the buttons are in the order of the ratings (like, dislike, ...) + await user.click(getAllByRole('button')[0]); + + await vi.waitFor(() => expect(sent).toHaveBeenCalled()); + expect(sent.mock.calls[0][0]).toMatchObject({ eventId: '9' }); + }); + }); +}); + +describe('EventListEvents', () => { + const renderList = ( + entries: CatalogEventEntry[], + { + sortBy, + fetchMoreIfNeeded = vi.fn(), + }: { sortBy?: { id: string; desc: boolean }[]; fetchMoreIfNeeded?: () => void } = {}, + ) => + render( + withCategory(e)), + }} + sortBy={sortBy} + canReadSchedule + fetchMoreIfNeeded={fetchMoreIfNeeded} + />, + { appRootContextValue }, + ); + + const friday = (id: string, title: string, time = '14:00') => + buildCatalogEvent({ id, title, runs: [buildCatalogRun(`r${id}`, `2026-06-05T${time}:00Z`)] }); + const saturday = (id: string, title: string) => + buildCatalogEvent({ id, title, runs: [buildCatalogRun(`r${id}`, '2026-06-06T14:00:00Z')] }); + + it('shows a card for each event, in the order given', async () => { + const { getAllByRole } = await renderList([friday('1', 'First'), friday('2', 'Second')]); + + expect(getAllByRole('heading', { level: 4 }).map((heading) => heading.textContent)).toEqual(['First', 'Second']); + }); + + describe('in convention order', () => { + const sortBy = [{ id: 'first_scheduled_run_start', desc: false }]; + + it('puts a heading for each convention day above its first event', async () => { + const { getAllByRole } = await renderList([friday('1', 'First'), friday('2', 'Second'), saturday('3', 'Third')], { + sortBy, + }); + + const headings = getAllByRole('heading', { level: 3 }).map((heading) => heading.textContent); + expect(headings).toEqual([expect.stringMatching(/Friday/), expect.stringMatching(/Saturday/)]); + }); + + it('puts the first day’s heading above the very first event', async () => { + const { container } = await renderList([friday('1', 'First')], { sortBy }); + + const first = container.firstElementChild as HTMLElement; + expect(first.tagName).toBe('H3'); + }); + }); + + it('puts no day headings in when sorted any other way', async () => { + const { queryAllByRole } = await renderList([friday('1', 'First'), saturday('2', 'Second')], { + sortBy: [{ id: 'title', desc: false }], + }); + + expect(queryAllByRole('heading', { level: 3 })).toEqual([]); + }); +}); diff --git a/test/javascript/EventsApp/EventCatalog/EventList.test.tsx b/test/javascript/EventsApp/EventCatalog/EventList.test.tsx new file mode 100644 index 0000000000..04e2374508 --- /dev/null +++ b/test/javascript/EventsApp/EventCatalog/EventList.test.tsx @@ -0,0 +1,259 @@ +import { MockLink } from '@apollo/client/testing'; +import { vi } from 'vitest'; + +import { renderRoute, userEvent, waitFor } from '../../testUtils'; +import { Component as EventList, loader } from '../../../../app/javascript/EventsApp/EventCatalog/EventList'; +import { EventListEventsQueryDocument } from '../../../../app/javascript/EventsApp/EventCatalog/EventList/queries.generated'; +import { CommonConventionDataQueryDocument } from '../../../../app/javascript/EventsApp/queries.generated'; +import { buildMultipleChoiceItem } from '../../fixtures/formItems'; +import { + buildCatalogCategory, + buildCatalogConvention, + buildCatalogEvent, + buildCatalogFormItem, + buildCatalogRun, + buildEventListData, + CatalogEventEntry, +} from '../../fixtures/eventCatalog'; + +describe('EventList (the event catalog)', () => { + let user: ReturnType; + const queried = vi.fn(); + + beforeEach(() => { + user = userEvent.setup(); + queried.mockReset(); + }); + + const tabletop = () => buildCatalogCategory({ id: '4', name: 'Tabletop RPG' }); + const larp = () => buildCatalogCategory({ id: '5', name: 'Larp' }); + + const renderPage = ({ + entries = [buildCatalogEvent({ id: '1', title: 'Alpha' })], + path = '/events', + myProfile = false, + canReadSchedule = true, + categories = [tabletop(), larp()], + listResult, + }: { + entries?: CatalogEventEntry[]; + path?: string; + myProfile?: boolean; + canReadSchedule?: boolean; + categories?: ReturnType[]; + listResult?: MockLink.MockedResponse['result']; + } = {}) => { + const apolloMocks: MockLink.MockedResponse[] = [ + { + request: { query: CommonConventionDataQueryDocument }, + result: { data: { __typename: 'Query', convention: buildCatalogConvention(categories) } }, + maxUsageCount: 20, + }, + { + request: { + query: EventListEventsQueryDocument, + variables: (variables: unknown) => { + queried(variables); + return true; + }, + }, + result: listResult ?? { data: buildEventListData(entries, { canReadSchedule }) }, + maxUsageCount: 50, + }, + ]; + return renderRoute( + [ + { path: '/events', loader, Component: EventList }, + { path: '/events/:id', Component: () =>
Event page
}, + ], + { + apolloMocks, + initialEntries: [path], + appRootContextValue: { + timezoneName: 'UTC', + ...(myProfile ? { myProfile: { __typename: 'UserConProfile', id: '2', name: 'Me' } as never } : {}), + }, + }, + ); + }; + + const lastVariables = () => queried.mock.calls[queried.mock.calls.length - 1][0]; + + it('lists the events with a heading and tabs for the list and table views', async () => { + const r = await renderPage({ + entries: [buildCatalogEvent({ id: '1', title: 'Alpha' }), buildCatalogEvent({ id: '2', title: 'Beta' })], + }); + + expect(await r.findByRole('link', { name: 'Alpha' })).toBeTruthy(); + expect(r.getByRole('link', { name: 'Beta' })).toBeTruthy(); + expect(r.getByRole('heading', { level: 1, name: 'Event Catalog' })).toBeTruthy(); + expect(r.getByRole('link', { name: 'Table view' }).getAttribute('href')).toBe('/events/table'); + }); + + describe('what it asks for by default', () => { + it('sorts by title with every category, for a visitor', async () => { + const r = await renderPage(); + await r.findByRole('link', { name: 'Alpha' }); + + expect(lastVariables()).toMatchObject({ + page: 1, + pageSize: 20, + sort: [{ field: 'title', desc: false }], + filters: { category: [] }, + }); + }); + + it('puts favorites first for someone with a profile, showing liked and unrated events', async () => { + const r = await renderPage({ myProfile: true }); + await r.findByRole('link', { name: 'Alpha' }); + + expect(lastVariables()).toMatchObject({ + sort: [ + { field: 'my_rating', desc: true }, + { field: 'title', desc: false }, + ], + filters: { my_rating: [1, 0], category: [] }, + }); + }); + + it('offers the rating filter only to people with a profile', async () => { + const visitor = await renderPage(); + await visitor.findByRole('link', { name: 'Alpha' }); + expect(visitor.queryByText('Show:')).toBeNull(); + visitor.unmount(); + + const member = await renderPage({ myProfile: true }); + expect(await member.findByText('Show:')).toBeTruthy(); + }); + }); + + describe('the URL', () => { + it('sets the sort order', async () => { + const r = await renderPage({ path: '/events?sort.created_at=desc' }); + await r.findByRole('link', { name: 'Alpha' }); + + expect(lastVariables().sort).toEqual([{ field: 'created_at', desc: true }]); + }); + + it('sets filters, decoding the lists', async () => { + const r = await renderPage({ path: '/events?filters.category=4,5&filters.text_search=dragon' }); + await r.findByRole('link', { name: 'Alpha' }); + + expect(lastVariables().filters).toMatchObject({ category: [4, 5], text_search: 'dragon' }); + }); + }); + + describe('searching', () => { + it('asks again with the text typed, and keeps it in the URL', async () => { + const r = await renderPage(); + await r.findByRole('link', { name: 'Alpha' }); + + await user.type(r.getByLabelText('Search'), 'dragon'); + + await waitFor(() => expect(lastVariables().filters).toMatchObject({ text_search: 'dragon' })); + }); + }); + + describe('sorting', () => { + it('offers convention order only to people who can read the schedule', async () => { + const allowed = await renderPage(); + await allowed.findByRole('link', { name: 'Alpha' }); + await user.click(allowed.getByRole('button', { name: /Sort by/ })); + expect(await allowed.findByRole('button', { name: 'Convention order' })).toBeTruthy(); + allowed.unmount(); + + const denied = await renderPage({ canReadSchedule: false }); + await denied.findByRole('link', { name: 'Alpha' }); + await user.click(denied.getByRole('button', { name: /Sort by/ })); + expect(await denied.findByRole('button', { name: 'Order added (newest first)' })).toBeTruthy(); + expect(denied.queryByRole('button', { name: 'Convention order' })).toBeNull(); + }); + + it('asks again in the chosen order', async () => { + const r = await renderPage(); + await r.findByRole('link', { name: 'Alpha' }); + + await user.click(r.getByRole('button', { name: /Sort by/ })); + await user.click(await r.findByRole('button', { name: 'Order added (newest first)' })); + + await waitFor(() => expect(lastVariables().sort).toEqual([{ field: 'created_at', desc: true }])); + }); + }); + + describe('convention order', () => { + it('puts a heading for each day above its first event', async () => { + const r = await renderPage({ + path: '/events?sort.first_scheduled_run_start=asc', + entries: [ + buildCatalogEvent({ id: '1', title: 'Alpha', runs: [buildCatalogRun('r1', '2026-06-05T14:00:00Z')] }), + buildCatalogEvent({ id: '2', title: 'Beta', runs: [buildCatalogRun('r2', '2026-06-06T14:00:00Z')] }), + ], + }); + + await r.findByRole('link', { name: 'Alpha' }); + expect(r.getAllByRole('heading', { level: 3 }).map((heading) => heading.textContent)).toEqual([ + expect.stringMatching(/Friday/), + expect.stringMatching(/Saturday/), + ]); + }); + }); + + describe('categories', () => { + it('describes the selection, all event types by default', async () => { + const r = await renderPage(); + + expect(await r.findByRole('button', { name: 'All event types' })).toBeTruthy(); + }); + + it('names a single selected category', async () => { + const r = await renderPage({ path: '/events?filters.category=5' }); + + expect(await r.findByRole('button', { name: 'Larp events' })).toBeTruthy(); + }); + + it('asks again for just the categories chosen', async () => { + const r = await renderPage(); + await user.click(await r.findByRole('button', { name: 'All event types' })); + + await user.click(await r.findByRole('checkbox', { name: 'Larp' })); + + await waitFor(() => expect(lastVariables().filters.category).toEqual([4])); + }); + }); + + describe('filterable form items', () => { + const system = buildMultipleChoiceItem({ + identifier: 'system', + caption: 'System', + choices: [ + { caption: 'D&D', value: 'dnd' }, + { caption: 'Fate', value: 'fate' }, + ], + }); + + it('gets a dropdown, and its identifier is fetched with the events', async () => { + const r = await renderPage({ + categories: [buildCatalogCategory({ id: '4' }, [buildCatalogFormItem(system, 'Game system')])], + }); + + expect(await r.findByRole('button', { name: 'Game system' })).toBeTruthy(); + expect(lastVariables().fetchFormItemIdentifiers).toEqual(['system']); + }); + + it('is left out for items that are not exposed in the catalog', async () => { + const r = await renderPage({ + categories: [buildCatalogCategory({ id: '4' }, [buildCatalogFormItem(system, 'Game system', false)])], + }); + await r.findByRole('link', { name: 'Alpha' }); + + expect(r.queryByRole('button', { name: 'Game system' })).toBeNull(); + expect(lastVariables().fetchFormItemIdentifiers).toEqual([]); + }); + }); + + it('shows the error if the events cannot be fetched', async () => { + const r = await renderPage({ listResult: { errors: [{ message: 'Search is down' } as never] } }); + + expect(await r.findByText(/Search is down/)).toBeTruthy(); + }); +}); diff --git a/test/javascript/EventsApp/RunList.test.tsx b/test/javascript/EventsApp/RunList.test.tsx new file mode 100644 index 0000000000..d6f281a023 --- /dev/null +++ b/test/javascript/EventsApp/RunList.test.tsx @@ -0,0 +1,241 @@ +import { MockLink } from '@apollo/client/testing'; +import { Suspense } from 'react'; +import { createRef } from 'react'; +import { vi } from 'vitest'; + +import { renderRoute, waitFor } from '../testUtils'; +import RunList from '../../../app/javascript/EventsApp/RunList'; +import { + ScheduleGridEventsQueryData, + ScheduleGridEventsQueryDocument, +} from '../../../app/javascript/EventsApp/ScheduleGrid/queries.generated'; +import { SignupState } from '../../../app/javascript/graphqlTypes.generated'; +import { buildCatalogCategory, buildCatalogConvention } from '../fixtures/eventCatalog'; + +type ScheduleEvent = ScheduleGridEventsQueryData['convention']['events'][number]; +type ScheduleRun = ScheduleEvent['runs'][number]; + +const run = (id: string, startsAt: string, overrides: Partial = {}): ScheduleRun => ({ + __typename: 'Run', + id, + starts_at: startsAt, + schedule_note: null, + title_suffix: null, + confirmed_signup_count: 0, + not_counted_signup_count: 0, + room_names: ['Ballroom'], + grouped_signup_counts: [], + my_signups: [], + my_signup_requests: [], + my_signup_ranked_choices: [], + ...overrides, +}); + +const event = ( + id: string, + title: string, + runs: ScheduleRun[], + overrides: Partial = {}, +): ScheduleEvent => ({ + __typename: 'Event', + id, + title, + length_seconds: 2 * 3600, + short_blurb_html: null, + my_rating: null, + can_play_concurrently: false, + form_response_attrs_json_with_rendered_markdown: null, + event_category: { __typename: 'EventCategory', id: '4' }, + registration_policy: null, + runs, + ...overrides, +}); + +describe('RunList', () => { + const queried = vi.fn(); + const convention = { ...buildCatalogConvention([buildCatalogCategory({ id: '4' })]), blockPartial: null }; + + const renderList = async ( + events: ScheduleEvent[], + { + search = '', + myProfile = false, + filters, + }: { search?: string; myProfile?: boolean; filters?: { text?: string } } = {}, + ) => { + const apolloMocks: MockLink.MockedResponse[] = [ + { + request: { + query: ScheduleGridEventsQueryDocument, + variables: (variables: unknown) => { + queried(variables); + return true; + }, + }, + result: { data: { __typename: 'Query', convention: { __typename: 'Convention', id: '1', events } } }, + maxUsageCount: 10, + }, + ]; + + return renderRoute( + [ + { + path: '/events/schedule', + Component: () => ( + Loading schedule}> + ()} + /> + + ), + }, + ], + { + apolloMocks, + initialEntries: [`/events/schedule${search}`], + appRootContextValue: { + timezoneName: 'UTC', + ...(myProfile ? { myProfile: { __typename: 'UserConProfile', id: '2', name: 'Me' } as never } : {}), + }, + }, + ); + }; + + beforeEach(() => queried.mockReset()); + + it('asks for the events with the filters and the form item identifiers', async () => { + const { findByText } = await renderList([event('1', 'Alpha', [run('r1', '2026-06-05T14:00:00Z')])], { + filters: { text: 'dragon' }, + }); + + await findByText(/Alpha/); + expect(queried).toHaveBeenCalledWith( + expect.objectContaining({ filters: { text_search: 'dragon' }, fetchFormItemIdentifiers: ['system'] }), + ); + }); + + it('groups runs under a heading for each day', async () => { + const { findAllByRole } = await renderList([ + event('1', 'Alpha', [run('r1', '2026-06-05T14:00:00Z'), run('r3', '2026-06-06T14:00:00Z')]), + event('2', 'Beta', [run('r2', '2026-06-05T18:00:00Z')]), + ]); + + const headings = (await findAllByRole('heading', { level: 3 })).map((heading) => heading.textContent); + expect(headings).toEqual([expect.stringMatching(/Friday/), expect.stringMatching(/Saturday/)]); + }); + + it('shows a time heading, once, for runs starting together, ordered by title', async () => { + const { findByText, getAllByText, container } = await renderList([ + event('1', 'Zulu', [run('r1', '2026-06-05T14:00:00Z')]), + event('2', 'alpha', [run('r2', '2026-06-05T14:00:00Z')]), + event('3', 'Later', [run('r3', '2026-06-05T18:00:00Z')]), + ]); + + await findByText(/alpha/); + expect(getAllByText(/2:00pm/, { selector: 'div.mt-2' })).toHaveLength(1); + expect(getAllByText(/6:00pm/, { selector: 'div.mt-2' })).toHaveLength(1); + const text = container.textContent ?? ''; + expect(text.indexOf('alpha')).toBeLessThan(text.indexOf('Zulu')); + expect(text.indexOf('Zulu')).toBeLessThan(text.indexOf('Later')); + }); + + it('shows each run’s title with when it ends', async () => { + const { findByText } = await renderList([event('1', 'Alpha', [run('r1', '2026-06-05T14:00:00Z')])]); + + expect(await findByText(/Alpha - until 4:00pm/)).toBeTruthy(); + }); + + it('shows nothing for no runs', async () => { + const { queryAllByRole, queryByText } = await renderList([event('1', 'Unscheduled', [])]); + + await waitFor(() => expect(queryByText('Loading schedule')).toBeNull()); + expect(queryAllByRole('heading', { level: 3 })).toEqual([]); + }); + + describe('for someone with a profile', () => { + const events = () => [ + event('1', 'Liked', [run('r1', '2026-06-05T14:00:00Z')], { my_rating: 1 }), + event('2', 'Unrated', [run('r2', '2026-06-05T14:00:00Z')], { my_rating: null }), + event('3', 'Disliked', [run('r3', '2026-06-05T14:00:00Z')], { my_rating: -1 }), + ]; + + it('hides disliked events by default, and says how many are hidden', async () => { + const { findByText, queryByText } = await renderList(events(), { + myProfile: true, + search: '?filters.my_rating=1,0&filters.hide_conflicts=false', + }); + + expect(await findByText(/Liked - until/)).toBeTruthy(); + expect(queryByText(/Disliked - until/)).toBeNull(); + expect(await findByText('+1 hidden')).toBeTruthy(); + }); + + it('follows the rating filter in the URL', async () => { + const { findByText, queryByText } = await renderList(events(), { + myProfile: true, + search: '?filters.my_rating=-1&filters.hide_conflicts=false', + }); + + expect(await findByText(/Disliked - until/)).toBeTruthy(); + expect(queryByText(/Liked - until/)).toBeNull(); + expect(await findByText('+2 hidden')).toBeTruthy(); + }); + + it('hides runs that overlap one the user is signed up for when asked to hide conflicts, but not the signed-up run', async () => { + const { findByText, queryByText } = await renderList( + [ + event( + '1', + 'Signed Up', + [ + run('r1', '2026-06-05T14:00:00Z', { + my_signups: [{ __typename: 'Signup', id: 's1', state: SignupState.Confirmed }], + }), + ], + { my_rating: 1 }, + ), + event('2', 'Overlapping', [run('r2', '2026-06-05T15:00:00Z')], { my_rating: 1 }), + event('3', 'Clear', [run('r3', '2026-06-05T20:00:00Z')], { my_rating: 1 }), + ], + { myProfile: true, search: '?filters.my_rating=1&filters.hide_conflicts=true' }, + ); + + expect(await findByText(/Clear - until/)).toBeTruthy(); + expect(await findByText(/Signed Up - until/)).toBeTruthy(); + expect(queryByText(/Overlapping - until/)).toBeNull(); + expect(await findByText('+1 hidden')).toBeTruthy(); + }); + + it('shows overlapping runs when not hiding conflicts', async () => { + const { findByText } = await renderList( + [ + event( + '1', + 'Signed Up', + [ + run('r1', '2026-06-05T14:00:00Z', { + my_signups: [{ __typename: 'Signup', id: 's1', state: SignupState.Confirmed }], + }), + ], + { my_rating: 1 }, + ), + event('2', 'Overlapping', [run('r2', '2026-06-05T15:00:00Z')], { my_rating: 1 }), + ], + { myProfile: true, search: '?filters.my_rating=1&filters.hide_conflicts=false' }, + ); + + expect(await findByText(/Overlapping - until/)).toBeTruthy(); + }); + }); + + it('does not filter by rating for someone who is not signed in', async () => { + const { findByText } = await renderList([ + event('1', 'Disliked', [run('r1', '2026-06-05T14:00:00Z')], { my_rating: -1 }), + ]); + + expect(await findByText(/Disliked - until/)).toBeTruthy(); + }); +}); diff --git a/test/javascript/EventsApp/ScheduleApp.test.tsx b/test/javascript/EventsApp/ScheduleApp.test.tsx new file mode 100644 index 0000000000..8525eefa21 --- /dev/null +++ b/test/javascript/EventsApp/ScheduleApp.test.tsx @@ -0,0 +1,254 @@ +import { vi } from 'vitest'; +import { Suspense } from 'react'; +import { MockLink } from '@apollo/client/testing'; +import { DateTime } from 'luxon'; + +import { renderRoute, userEvent, waitFor } from '../testUtils'; +import { Component as ScheduleApp } from '../../../app/javascript/EventsApp/ScheduleApp'; +import { ScheduleGridConventionDataQueryData } from '../../../app/javascript/EventsApp/ScheduleGrid/queries.generated'; +import { ScheduleGridEventsQueryDocument } from '../../../app/javascript/EventsApp/ScheduleGrid/queries.generated'; +import { appRootContextDefaultValue, AppRootContextValue } from '../../../app/javascript/AppRootContext'; +import { SiteMode } from '../../../app/javascript/graphqlTypes.generated'; +import Timespan from '../../../app/javascript/Timespan'; +import { buildMultipleChoiceItem } from '../fixtures/formItems'; +import { buildCatalogCategory, buildCatalogConvention, buildCatalogFormItem } from '../fixtures/eventCatalog'; + +// The grids are big and have their own tests; here they're a marker saying which one was asked for +vi.mock('../../../app/javascript/EventsApp/ScheduleGrid', () => ({ + default: ({ configKey, fetchFormItemIdentifiers }: { configKey: string; fetchFormItemIdentifiers: string[] }) => ( +
+ grid: {configKey} ({fetchFormItemIdentifiers.join(',')}) +
+ ), +})); + +const SHORT_CON = Timespan.finiteFromDateTimes( + DateTime.fromISO('2026-06-05T00:00:00Z'), + DateTime.fromISO('2026-06-07T23:00:00Z'), +); +const LONG_CON = Timespan.finiteFromDateTimes( + DateTime.fromISO('2026-06-01T00:00:00Z'), + DateTime.fromISO('2026-06-20T23:00:00Z'), +); + +describe('ScheduleApp', () => { + let user: ReturnType; + + beforeEach(() => { + user = userEvent.setup(); + window.localStorage.clear(); + }); + + afterEach(() => { + window.localStorage.clear(); + vi.restoreAllMocks(); + }); + + const loaderData = ( + categories = [buildCatalogCategory({ id: '4' })], + ): { data: ScheduleGridConventionDataQueryData } => ({ + data: { + __typename: 'Query', + currentAbility: { __typename: 'Ability', can_create_cms_partials: false }, + convention: { + ...buildCatalogConvention(categories), + blockPartial: null, + }, + }, + }); + + const renderPage = ({ + ability = {}, + conventionTimespan = SHORT_CON, + siteMode = SiteMode.Convention, + categories, + search = '', + myProfile = false, + }: { + ability?: Partial; + conventionTimespan?: Timespan; + siteMode?: SiteMode; + categories?: ReturnType[]; + search?: string; + myProfile?: boolean; + } = {}) => { + const apolloMocks: MockLink.MockedResponse[] = [ + { + request: { query: ScheduleGridEventsQueryDocument, variables: () => true }, + result: { data: { __typename: 'Query', convention: { __typename: 'Convention', id: '1', events: [] } } }, + maxUsageCount: 10, + }, + ]; + return renderRoute( + [ + { + path: '/events/schedule', + loader: () => loaderData(categories), + Component: () => ( + Loading}> + + + ), + }, + ], + { + apolloMocks, + initialEntries: [`/events/schedule${search}`], + appRootContextValue: { + timezoneName: 'UTC', + conventionTimespan, + siteMode, + currentAbility: { + ...appRootContextDefaultValue.currentAbility, + can_read_schedule: true, + can_read_schedule_with_counts: false, + ...ability, + }, + ...(myProfile ? { myProfile: { __typename: 'UserConProfile', id: '2', name: 'Me' } as never } : {}), + }, + }, + ); + }; + + type Rendered = Awaited>; + + const viewNames = async (r: Rendered) => { + await user.click(await r.findByRole('button', { name: /View type/ })); + return r + .getAllByRole('button') + .filter((button) => button.classList.contains('dropdown-item')) + .map((b) => b.textContent); + }; + + it('has a breadcrumb for the event schedule', async () => { + const r = await renderPage(); + + expect(await r.findByText('Event Schedule')).toBeTruthy(); + }); + + describe('choosing the view', () => { + it('starts on the first grid on a wide screen', async () => { + const r = await renderPage(); + + expect(await r.findByText(/grid: con_schedule \(/)).toBeTruthy(); + }); + + it('starts on the list on a narrow screen', async () => { + vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(500); + const r = await renderPage(); + + expect(await r.findByRole('button', { name: /View type/ })).toHaveTextContent(/List view/); + expect(r.queryByText(/grid:/)).toBeNull(); + }); + + it('goes back to the view that was chosen last time', async () => { + window.localStorage.setItem('schedule:view', 'con_schedule_by_room'); + const r = await renderPage(); + + expect(await r.findByText(/grid: con_schedule_by_room/)).toBeTruthy(); + }); + + it('ignores a remembered view that does not exist', async () => { + window.localStorage.setItem('schedule:view', 'nonsense'); + const r = await renderPage(); + + expect(await r.findByText(/grid: con_schedule \(/)).toBeTruthy(); + }); + + it('lets the view be chosen, and remembers it', async () => { + const r = await renderPage(); + await r.findByText(/grid: con_schedule \(/); + + await user.click(await r.findByRole('button', { name: /View type/ })); + await user.click(await r.findByRole('button', { name: 'List view' })); + + expect(window.localStorage.getItem('schedule:view')).toBe('list'); + await waitFor(() => expect(r.queryByText(/grid:/)).toBeNull()); + }); + + it('does not offer the grid with counts to someone who cannot read the schedule with counts', async () => { + const r = await renderPage(); + + const names = await viewNames(r); + + expect(names).toContain('List view'); + expect(names).not.toContain('Grid view with counts'); + }); + + it('offers the grid with counts to someone who can', async () => { + const r = await renderPage({ ability: { can_read_schedule_with_counts: true } }); + + expect(await viewNames(r)).toContain('Grid view with counts'); + }); + + it('offers only the list for a convention a week or longer, which needs dates', async () => { + const r = await renderPage({ conventionTimespan: LONG_CON }); + + await waitFor(() => expect(r.queryByRole('button', { name: /View type: List view/ })).toBeNull()); + expect(r.queryByText(/grid:/)).toBeNull(); + }); + + it('falls back to the list if the remembered view is a grid that is not available', async () => { + window.localStorage.setItem('schedule:view', 'schedule_with_counts'); + const r = await renderPage({ ability: { can_read_schedule_with_counts: false } }); + + await waitFor(() => expect(r.queryByText(/grid: schedule_with_counts/)).toBeNull()); + expect(await r.findByText(/grid: con_schedule \(/)).toBeTruthy(); + }); + }); + + describe('authorization', () => { + it('says so, instead of showing a schedule, to someone who cannot read it', async () => { + const r = await renderPage({ ability: { can_read_schedule: false } }); + + expect(await r.findByText(/not authorized/)).toBeTruthy(); + expect(r.queryByText(/grid:/)).toBeNull(); + }); + + it('moves someone off the counts grid to a plain one if they can only read the plain schedule', async () => { + window.localStorage.setItem('schedule:view', 'schedule_with_counts'); + const r = await renderPage({ ability: { can_read_schedule: true, can_read_schedule_with_counts: false } }); + + expect(await r.findByText(/grid: con_schedule \(/)).toBeTruthy(); + }); + }); + + describe('filters', () => { + const system = buildMultipleChoiceItem({ + identifier: 'system', + caption: 'System', + choices: [ + { caption: 'D&D', value: 'dnd' }, + { caption: 'Fate', value: 'fate' }, + ], + }); + const withSystem = () => [buildCatalogCategory({ id: '4' }, [buildCatalogFormItem(system, 'Game system')])]; + + it('offers a dropdown for each filterable form item, and fetches those items with the schedule', async () => { + const r = await renderPage({ categories: withSystem() }); + + expect(await r.findByRole('button', { name: 'Game system' })).toBeTruthy(); + expect(await r.findByText(/grid: con_schedule \(system\)/)).toBeTruthy(); + }); + + it('offers none when no form items are exposed', async () => { + const r = await renderPage(); + await r.findByText(/grid:/); + + expect(r.queryByRole('button', { name: 'Game system' })).toBeNull(); + }); + + it('offers the personal filters on the grids that have them, not on the one with counts', async () => { + const withFilters = await renderPage(); + await withFilters.findByText(/grid: con_schedule \(/); + // (the filters dropdown is the only thing with a Select all) + expect(withFilters.getByRole('button', { name: 'Select all' })).toBeTruthy(); + withFilters.unmount(); + + window.localStorage.setItem('schedule:view', 'schedule_with_counts'); + const withCounts = await renderPage({ ability: { can_read_schedule_with_counts: true } }); + await withCounts.findByText(/grid: schedule_with_counts/); + expect(withCounts.queryByRole('button', { name: 'Select all' })).toBeNull(); + }); + }); +}); diff --git a/test/javascript/EventsApp/useFilterableFormItems.test.ts b/test/javascript/EventsApp/useFilterableFormItems.test.ts new file mode 100644 index 0000000000..61dacf8301 --- /dev/null +++ b/test/javascript/EventsApp/useFilterableFormItems.test.ts @@ -0,0 +1,87 @@ +import { vi } from 'vitest'; + +import { + getFilterableEventFormItems, + mergeFormItemsAcrossForms, +} from '../../../app/javascript/EventsApp/useFilterableFormItems'; +import { MultipleChoiceFormItem } from '../../../app/javascript/FormAdmin/FormItemUtils'; +import { buildFreeTextItem, buildMultipleChoiceItem } from '../fixtures/formItems'; +import { buildCatalogCategory, buildCatalogConvention, buildCatalogFormItem } from '../fixtures/eventCatalog'; + +const choices = (item: unknown) => (item as MultipleChoiceFormItem).rendered_properties.choices.map((c) => c.value); + +describe('getFilterableEventFormItems', () => { + const system = (id: string, values: string[]) => + buildCatalogFormItem( + { + ...buildMultipleChoiceItem({ + identifier: 'system', + choices: values.map((value) => ({ caption: value.toUpperCase(), value })), + }), + id, + }, + 'Game system', + ); + + it('is empty when nothing is exposed in the catalog', () => { + const hidden = buildCatalogFormItem(buildFreeTextItem({ identifier: 'secret' }), 'Secret', false); + + expect(getFilterableEventFormItems(buildCatalogConvention([buildCatalogCategory({}, [hidden])]))).toEqual([]); + }); + + it('includes the exposed items, once each, across categories', () => { + const convention = buildCatalogConvention([ + buildCatalogCategory({ id: '4' }, [buildCatalogFormItem(buildFreeTextItem({ identifier: 'theme' }), 'Theme')]), + buildCatalogCategory({ id: '5' }, [ + buildCatalogFormItem({ ...buildFreeTextItem({ identifier: 'theme' }), id: 'other-theme' }, 'Theme'), + ]), + ]); + + const items = getFilterableEventFormItems(convention); + + expect(items.map((item) => item.identifier)).toEqual(['theme']); + }); + + it('merges the choices of a multiple choice item that several categories have, without duplicates', () => { + const convention = buildCatalogConvention([ + buildCatalogCategory({ id: '4' }, [system('a', ['dnd', 'fate'])]), + buildCatalogCategory({ id: '5' }, [system('b', ['fate', 'gurps'])]), + ]); + + const [item] = getFilterableEventFormItems(convention); + + expect(choices(item)).toEqual(['dnd', 'fate', 'gurps']); + }); +}); + +describe('mergeFormItemsAcrossForms', () => { + const form = (...items: ReturnType[]) => ({ form_sections: [{ form_items: items }] }); + + it('keeps items with different identifiers separate', () => { + const merged = mergeFormItemsAcrossForms([ + form( + buildCatalogFormItem(buildFreeTextItem({ identifier: 'one' }), 'One'), + buildCatalogFormItem({ ...buildFreeTextItem({ identifier: 'two' }), id: 'two' }, 'Two'), + ), + ]); + + expect(merged.map((item) => item.identifier)).toEqual(['one', 'two']); + }); + + it('warns and takes the first when items of different types share an identifier', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const merged = mergeFormItemsAcrossForms([ + form(buildCatalogFormItem(buildFreeTextItem({ identifier: 'clash' }), 'Clash')), + form(buildCatalogFormItem({ ...buildMultipleChoiceItem({ identifier: 'clash' }), id: 'mc' }, 'Clash')), + ]); + + expect(merged).toHaveLength(1); + expect(merged[0].item_type).toBe('free_text'); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("Don't know how to merge")); + warn.mockRestore(); + }); + + it('is empty for no forms', () => { + expect(mergeFormItemsAcrossForms([])).toEqual([]); + }); +}); diff --git a/test/javascript/fixtures/eventCatalog.ts b/test/javascript/fixtures/eventCatalog.ts new file mode 100644 index 0000000000..4f25e4f6a8 --- /dev/null +++ b/test/javascript/fixtures/eventCatalog.ts @@ -0,0 +1,146 @@ +import { EventListEventsQueryData } from '../../../app/javascript/EventsApp/EventCatalog/EventList/queries.generated'; +import { CommonConventionDataFragment } from '../../../app/javascript/EventsApp/queries.generated'; +import { TypedFormItem } from '../../../app/javascript/FormAdmin/FormItemUtils'; +import { + FormItemExposeIn, + SchedulingUi, + SignupAutomationMode, + SignupMode, + SiteMode, + TicketMode, + TimezoneMode, +} from '../../../app/javascript/graphqlTypes.generated'; + +export type CatalogCategory = CommonConventionDataFragment['event_categories'][number]; +export type CatalogEventEntry = EventListEventsQueryData['convention']['events_paginated']['entries'][number]; +export type CatalogTeamMember = CatalogEventEntry['team_members'][number]; + +// form items that are shown in the catalog (item.expose_in) +export function buildCatalogFormItem(item: TypedFormItem, publicDescription: string, exposeIn = true) { + return { + __typename: 'FormItem' as const, + id: item.id, + public_description: publicDescription, + default_value: null, + position: 1, + identifier: item.identifier ?? null, + item_type: item.item_type, + rendered_properties: JSON.stringify(item.rendered_properties), + visibility: item.visibility, + writeability: item.writeability, + expose_in: exposeIn ? [FormItemExposeIn.EventCatalog] : null, + }; +} + +export function buildCatalogCategory( + overrides: Partial = {}, + formItems: ReturnType[] = [], +): CatalogCategory { + return { + __typename: 'EventCategory', + id: '4', + name: 'Tabletop RPG', + scheduling_ui: SchedulingUi.Regular, + default_color: '#336699', + full_color: '#999999', + signed_up_color: '#cc9900', + team_member_name: 'GM', + teamMemberNamePlural: 'GMs', + event_form: { + __typename: 'Form', + id: `form-${overrides.id ?? '4'}`, + form_sections: [{ __typename: 'FormSection', id: `section-${overrides.id ?? '4'}`, form_items: formItems }], + }, + ...overrides, + }; +} + +export function buildCatalogConvention( + categories: CatalogCategory[] = [buildCatalogCategory()], + overrides: Partial = {}, +): CommonConventionDataFragment { + return { + __typename: 'Convention', + id: '1', + name: 'Test Con', + starts_at: '2026-06-05T00:00:00Z', + ends_at: '2026-06-07T23:00:00Z', + signup_mode: SignupMode.SelfService, + signup_automation_mode: SignupAutomationMode.None, + site_mode: SiteMode.Convention, + timezone_name: 'UTC', + timezone_mode: TimezoneMode.ConventionLocal, + ticket_name: 'ticket', + ticket_mode: TicketMode.Disabled, + event_categories: categories, + ...overrides, + }; +} + +export function buildCatalogTeamMember( + id: string, + name: string, + { lastName = name.split(' ').slice(-1)[0], display = true, gravatar = false } = {}, +): CatalogTeamMember { + return { + __typename: 'TeamMember', + id, + display_team_member: display, + user_con_profile: { + __typename: 'UserConProfile', + id: `ucp-${id}`, + last_name: lastName, + name_without_nickname: name, + gravatar_enabled: gravatar, + gravatar_url: 'https://example.com/avatar.png', + }, + }; +} + +export function buildCatalogEvent( + overrides: Partial & { form_response_attrs?: Record } = {}, +): CatalogEventEntry { + const { form_response_attrs: formResponseAttrs, ...rest } = overrides; + return { + __typename: 'Event', + id: '9', + title: 'Big Game', + created_at: '2026-01-02T12:00:00Z', + short_blurb_html: '

A fine game

', + form_response_attrs_json_with_rendered_markdown: JSON.stringify(formResponseAttrs ?? {}), + my_rating: null, + length_seconds: 3 * 3600, + event_category: { __typename: 'EventCategory', id: '4' }, + runs: [], + team_members: [], + registration_policy: null, + ...rest, + }; +} + +export function buildCatalogRun(id: string, startsAt: string) { + return { __typename: 'Run' as const, id, starts_at: startsAt }; +} + +export function buildEventListData( + entries: CatalogEventEntry[], + { canReadSchedule = true, currentPage = 1, totalPages = 1 } = {}, +): EventListEventsQueryData { + return { + __typename: 'Query', + currentAbility: { __typename: 'Ability', can_read_schedule: canReadSchedule }, + convention: { + __typename: 'Convention', + id: '1', + timezone_mode: TimezoneMode.ConventionLocal, + events_paginated: { + __typename: 'EventsPagination', + total_entries: entries.length, + total_pages: totalPages, + current_page: currentPage, + per_page: 20, + entries, + }, + }, + }; +}