diff --git a/src/actions/__tests__/scheduleActions.test.js b/src/actions/__tests__/scheduleActions.test.js new file mode 100644 index 00000000..bffd5148 --- /dev/null +++ b/src/actions/__tests__/scheduleActions.test.js @@ -0,0 +1,46 @@ +import { + updateFiltersFromHash, + updateCustomEventIdsFromHash, +} from '../schedule-actions'; + +const setHash = (hash) => { + window.location.hash = hash; +}; + +afterEach(() => { + setHash(''); +}); + +it('keeps hash-applied facet filters across the follow-up pass', async () => { + window.location.hash = '#event_ids=1,2&track=5'; + const dispatch1 = jest.fn(); + const filters = { track: { label: 'Track', values: [], options: ['5', '6'] } }; + + await updateFiltersFromHash('schedKey', filters, null)(dispatch1); + + // pass 1 applies the filter and rewrites the hash keeping event_ids + expect(dispatch1.mock.calls[0][0].payload.filters.track.values).toEqual([5]); + expect(window.location.hash).toBe('#event_ids=1,2'); + + // the hash change re-runs the [key, location.hash] effect (pass 2), + // now with the state filters holding the applied values + const dispatch2 = jest.fn(); + const filtersAfterPass1 = { track: { label: 'Track', values: [5], options: ['5', '6'] } }; + + await updateFiltersFromHash('schedKey', filtersAfterPass1, null)(dispatch2); + + // desired: no filter params left in the hash -> keep state untouched + expect(dispatch2).not.toHaveBeenCalled(); +}); + +it('does not dispatch when the hash has no event_ids and state already holds none', () => { + window.location.hash = '#track=5'; + const dispatch = jest.fn(); + const getState = () => ({ + allSchedulesState: { schedules: [{ key: 'schedKey', customEventIds: [] }] } + }); + + updateCustomEventIdsFromHash('schedKey')(dispatch, getState); + + expect(dispatch).not.toHaveBeenCalled(); +}); diff --git a/src/actions/__tests__/shareLinkHash.test.js b/src/actions/__tests__/shareLinkHash.test.js new file mode 100644 index 00000000..2ee244d2 --- /dev/null +++ b/src/actions/__tests__/shareLinkHash.test.js @@ -0,0 +1,113 @@ +import { + updateCustomEventIdsFromHash, + updateFiltersFromHash, + getShareLink, + SET_CUSTOM_EVENT_IDS, +} from '../schedule-actions'; + +const setHash = (hash) => { + window.location.hash = hash; +}; + +// customEventIds deliberately doesn't match any value computed below, so the +// "unchanged" short-circuit in updateCustomEventIdsFromHash never suppresses dispatch here +const getState = () => ({ + allSchedulesState: { schedules: [{ key: 'schedKey', customEventIds: [999] }] } +}); + +afterEach(() => { + setHash(''); +}); + +describe('updateCustomEventIdsFromHash', () => { + it('parses a numeric event_ids list from the hash', () => { + setHash('#event_ids=1,2,3'); + const dispatch = jest.fn(); + + updateCustomEventIdsFromHash('schedKey')(dispatch, getState); + + expect(dispatch).toHaveBeenCalledWith({ + type: SET_CUSTOM_EVENT_IDS, + payload: { customEventIds: [1, 2, 3], key: 'schedKey' }, + }); + }); + + it('drops unknown/non-numeric ids without throwing', () => { + setHash('#event_ids=1,abc,3'); + const dispatch = jest.fn(); + + expect(() => updateCustomEventIdsFromHash('schedKey')(dispatch, getState)).not.toThrow(); + expect(dispatch).toHaveBeenCalledWith({ + type: SET_CUSTOM_EVENT_IDS, + payload: { customEventIds: [1, 3], key: 'schedKey' }, + }); + }); + + it('dispatches an empty list when event_ids is absent from the hash', () => { + setHash('#track=5'); + const dispatch = jest.fn(); + + updateCustomEventIdsFromHash('schedKey')(dispatch, getState); + + expect(dispatch).toHaveBeenCalledWith({ + type: SET_CUSTOM_EVENT_IDS, + payload: { customEventIds: [], key: 'schedKey' }, + }); + }); + + it('is not affected by the whole-fragment lowercasing of the hash param key', () => { + setHash('#EVENT_IDS=1,2'); + const dispatch = jest.fn(); + + updateCustomEventIdsFromHash('schedKey')(dispatch, getState); + + expect(dispatch).toHaveBeenCalledWith({ + type: SET_CUSTOM_EVENT_IDS, + payload: { customEventIds: [1, 2], key: 'schedKey' }, + }); + }); +}); + +describe('updateFiltersFromHash - event_ids isolation (mitigation for verified points 7 & 8)', () => { + it('never includes event_ids as a key of the dispatched filters object', () => { + setHash('#event_ids=10,20&track=5'); + const dispatch = jest.fn(); + const filters = { + track: { label: 'Track', values: [], options: ['5', '6'] }, + }; + + updateFiltersFromHash('schedKey', filters, null)(dispatch); + + expect(dispatch).toHaveBeenCalled(); + const dispatchedFilters = dispatch.mock.calls[0][0].payload.filters; + expect(Object.keys(dispatchedFilters)).not.toContain('event_ids'); + }); +}); + +describe('getShareLink - event_ids passthrough (regression, verified point 5)', () => { + it('keeps event_ids in the link when an active filter exists', () => { + setHash('#event_ids=10,20&track=5'); + const filters = { track: { values: ['5'], options: ['5', '6'] } }; + + const link = getShareLink(filters, null); + + expect(link).toContain('event_ids=10,20'); + }); + + it('keeps event_ids in the link when all filters are empty', () => { + setHash('#event_ids=10,20'); + const filters = { track: { values: [], options: ['5', '6'] } }; + + const link = getShareLink(filters, null); + + expect(link).toContain('event_ids=10,20'); + }); + + it('keeps event_ids in the link when view is null and no filters are passed', () => { + setHash('#event_ids=10,20'); + + const link = getShareLink(null, null); + + expect(link).toContain('event_ids=10,20'); + }); +}); diff --git a/src/actions/schedule-actions.js b/src/actions/schedule-actions.js index ac6c9f24..77a410bb 100644 --- a/src/actions/schedule-actions.js +++ b/src/actions/schedule-actions.js @@ -1,5 +1,6 @@ import { createAction } from "openstack-uicore-foundation/lib/utils/actions"; import FragmentParser from "openstack-uicore-foundation/lib/utils/fragment-parser"; +import * as Sentry from "@sentry/react"; import { pickBy, isEqual, isEmpty } from "lodash"; @@ -9,6 +10,7 @@ export const CLEAR_FILTERS = "CLEAR_FILTERS"; export const CHANGE_VIEW = "CHANGE_VIEW"; export const CHANGE_TIMEZONE = "CHANGE_TIMEZONE"; export const CHANGE_TIME_FORMAT = "CHANGE_TIME_FORMAT"; +export const SET_CUSTOM_EVENT_IDS = "SET_CUSTOM_EVENT_IDS"; /** * This action is defined to just reinitialize the allScheduleReducer state @@ -86,8 +88,7 @@ export const updateFiltersFromHash = window.location.hash = fragment; } - // escape if no filter hash - if (isEmpty(qsFilters)) return; + if (isEmpty(pickBy(qsFilters, (value, key) => key !== "event_ids"))) return; // remove any query vars that are not filters const normalizedFilters = pickBy(qsFilters, (value, key) => @@ -125,6 +126,29 @@ export const updateFiltersFromHash = } }; +export const updateCustomEventIdsFromHash = (key) => (dispatch, getState) => { + const rawEventIds = fragmentParser.getParam("event_ids"); + + let decoded = ""; + try { + decoded = rawEventIds ? decodeURIComponent(rawEventIds) : ""; + } catch (e) { + Sentry?.captureMessage(`event_ids hash param is malformed and could not be decoded: ${rawEventIds}`); + decoded = ""; + } + + const customEventIds = decoded + .split(",") + .filter((val) => val !== "" && !isNaN(val)) + .map((val) => parseInt(val)); + + const current = getState().allSchedulesState.schedules + .find((s) => s.key === key)?.customEventIds ?? []; + if (isEqual(customEventIds, current)) return; + + dispatch(createAction(SET_CUSTOM_EVENT_IDS)({ customEventIds, key })); +}; + export const getShareLink = (filters, view) => { const hashVars = {}; diff --git a/src/reducers/__tests__/scheduleReducer.test.js b/src/reducers/__tests__/scheduleReducer.test.js index f41d3cd3..aad9160b 100644 --- a/src/reducers/__tests__/scheduleReducer.test.js +++ b/src/reducers/__tests__/scheduleReducer.test.js @@ -46,6 +46,7 @@ describe('scheduleReducer - SCHED_CLEAR_FILTERS', () => { level: { label: 'Level', order: 2, values: [], options: ['Beginner', 'Advanced'] } }, events: ['some-event'], + customEventIds: [1, 2], hide_past_events_with_show_always_on_schedule: false }; @@ -60,15 +61,18 @@ describe('scheduleReducer - SCHED_CLEAR_FILTERS', () => { level: { label: 'Level', order: 2, values: [], options: ['Beginner', 'Advanced'] } }); - // getFilteredEvents was called with correct arguments + // getFilteredEvents was called with correct arguments, including the untouched custom subset expect(getFilteredEvents).toHaveBeenCalledWith( initialState.allEvents, newState.filters, expect.any(String), // summitTimeZoneId - false + false, + initialState.customEventIds ); // events were updated from its return value expect(newState.events).toEqual(['filtered-event-1', 'filtered-event-2']); + // the custom subset survives clear-filters - it is not a facet + expect(newState.customEventIds).toEqual([1, 2]); }); it('should not mutate baseFilters object', () => { @@ -94,6 +98,7 @@ describe('scheduleReducer - SCHED_UPDATE_FILTER', () => { level: { label: 'Level', order: 2, values: [], options: ['Beginner', 'Advanced'] } }, events: [], + customEventIds: [1, 2], hide_past_events_with_show_always_on_schedule: false }; @@ -111,7 +116,13 @@ describe('scheduleReducer - SCHED_UPDATE_FILTER', () => { expect(state.filters.track.values).toEqual(['Ops']); expect(state.filters.level.values).toEqual(['Advanced']); // unchanged - expect(getFilteredEvents).toHaveBeenCalled(); + expect(getFilteredEvents).toHaveBeenCalledWith( + initialState.allEvents, + state.filters, + expect.any(String), + false, + initialState.customEventIds + ); expect(state.events).toEqual(['filtered-event-1', 'filtered-event-2']); }); @@ -130,6 +141,13 @@ describe('scheduleReducer - SCHED_UPDATE_FILTER', () => { expect(state.view).toBe('list'); expect(state.filters.topic.values).toEqual(['AI']); + expect(getFilteredEvents).toHaveBeenCalledWith( + initialState.allEvents, + action.payload.filters, + expect.any(String), + false, + initialState.customEventIds + ); expect(state.events).toEqual(['filtered-event-1', 'filtered-event-2']); }); @@ -149,6 +167,7 @@ describe('scheduleReducer - SCHED_RELOAD_SCHED_DATA', () => { allEvents: [], events: [], timeFormat: '12h', + customEventIds: [42], hide_past_events_with_show_always_on_schedule: false }; @@ -198,4 +217,107 @@ describe('scheduleReducer - SCHED_RELOAD_SCHED_DATA', () => { // even though 24h is in the payload. expect(newState.timeFormat).toBe('12h'); // keeps existing unless null }); + + it('keeps the custom subset applied across a full data reload (survives filters/baseFilters rebuild)', () => { + const newState = scheduleReducer(initialState, action); + + expect(newState.customEventIds).toEqual([42]); + expect(getFilteredEvents).toHaveBeenCalledWith( + ['event-1', 'event-2'], + newState.filters, + expect.any(String), + true, + [42] + ); + }); +}); + +describe('scheduleReducer - SCHED_SYNC_DATA (real-time update path)', () => { + // real-time updates (Ably/Supabase -> synch worker -> synchEntityData) dispatch the base + // SYNC_DATA action, which all-schedules-reducer.js routes here as SCHED_SYNC_DATA - the exact + // same branch as a manual SCHED_RELOAD_SCHED_DATA. This locks in that the custom subset survives + // a live event insert/update/delete, not just an explicit "reload schedule data" click. + const initialState = { + filters: {}, + baseFilters: {}, + allEvents: [], + events: [], + timeFormat: '12h', + customEventIds: [7879], + hide_past_events_with_show_always_on_schedule: false + }; + + const action = { + type: 'SCHED_SYNC_DATA', + payload: { + color_source: 'Track', + pre_filters: { topic: { values: ['Dev'] } }, + all_events: ['raw-event-1', 'raw-event-2'], + filters: { + topic: { label: 'Topic', values: [], options: ['Dev', 'Ops'], order: 1 } + }, + baseFilters: { + topic: { label: 'Topic', values: [], options: ['Dev', 'Ops'], order: 1 } + }, + only_events_with_attendee_access: false, + hide_past_events_with_show_always_on_schedule: false, + is_my_schedule: false, + isLoggedUser: true, + userProfile: { id: 123 }, + time_format: '24h' + } + }; + + it('keeps the custom subset applied when a live update pushes fresh event data', () => { + const newState = scheduleReducer(initialState, action); + + expect(newState.customEventIds).toEqual([7879]); + expect(getFilteredEvents).toHaveBeenCalledWith( + ['event-1', 'event-2'], + newState.filters, + expect.any(String), + false, + [7879] + ); + expect(newState.events).toEqual(['filtered-event-1', 'filtered-event-2']); + }); +}); + +describe('scheduleReducer - SCHED_SET_CUSTOM_EVENT_IDS', () => { + const initialState = { + allEvents: ['event1', 'event2'], + filters: { + track: { label: 'Track', order: 1, values: [], options: ['Dev', 'Ops'] } + }, + baseFilters: { + track: { label: 'Track', order: 1, values: [], options: ['Dev', 'Ops'] } + }, + events: [], + customEventIds: [], + hide_past_events_with_show_always_on_schedule: false + }; + + it('sets the custom subset and refilters using the existing filters', () => { + const action = { type: 'SCHED_SET_CUSTOM_EVENT_IDS', payload: { customEventIds: [1, 2, 3] } }; + + const newState = scheduleReducer(initialState, action); + + expect(newState.customEventIds).toEqual([1, 2, 3]); + expect(getFilteredEvents).toHaveBeenCalledWith( + initialState.allEvents, + initialState.filters, + expect.any(String), + false, + [1, 2, 3] + ); + expect(newState.events).toEqual(['filtered-event-1', 'filtered-event-2']); + }); + + it('never surfaces event_ids as a key of state.filters (mitigation for verified points 7 & 8)', () => { + const action = { type: 'SCHED_SET_CUSTOM_EVENT_IDS', payload: { customEventIds: [1, 2, 3] } }; + + const newState = scheduleReducer(initialState, action); + + expect(Object.keys(newState.filters)).not.toContain('event_ids'); + }); }); \ No newline at end of file diff --git a/src/reducers/all-schedules-reducer.js b/src/reducers/all-schedules-reducer.js index 538fa798..d76ed339 100644 --- a/src/reducers/all-schedules-reducer.js +++ b/src/reducers/all-schedules-reducer.js @@ -1,7 +1,7 @@ import scheduleReducer from './schedule-reducer'; import {filterEventsByTags} from '../utils/schedule'; import {LOGOUT_USER} from "openstack-uicore-foundation/lib/security/actions"; -import {CLEAR_FILTERS, UPDATE_FILTER, UPDATE_FILTERS, CHANGE_VIEW, CHANGE_TIMEZONE, CHANGE_TIME_FORMAT, RELOAD_SCHED_DATA , RELOAD_USER_PROFILE} from '../actions/schedule-actions' +import {CLEAR_FILTERS, UPDATE_FILTER, UPDATE_FILTERS, CHANGE_VIEW, CHANGE_TIMEZONE, CHANGE_TIME_FORMAT, RELOAD_SCHED_DATA , RELOAD_USER_PROFILE, SET_CUSTOM_EVENT_IDS} from '../actions/schedule-actions' import {RESET_STATE, SYNC_DATA} from "../actions/base-actions-definitions"; import {GET_EVENT_DATA} from '../actions/event-actions-definitions'; import {ADD_TO_SCHEDULE, REMOVE_FROM_SCHEDULE, GET_USER_PROFILE} from "../actions/user-actions"; @@ -102,7 +102,8 @@ const allSchedulesReducer = (state = DEFAULT_STATE, action) => { case CHANGE_VIEW: case CLEAR_FILTERS: case UPDATE_FILTERS: - case UPDATE_FILTER: { + case UPDATE_FILTER: + case SET_CUSTOM_EVENT_IDS: { const {key} = payload; const {schedules} = state; diff --git a/src/reducers/schedule-reducer.js b/src/reducers/schedule-reducer.js index feddf6e0..56cdf932 100644 --- a/src/reducers/schedule-reducer.js +++ b/src/reducers/schedule-reducer.js @@ -20,6 +20,7 @@ const INITIAL_STATE = { is_my_schedule: false, only_events_with_attendee_access: false, hide_past_events_with_show_always_on_schedule: false, + customEventIds: [], }; const scheduleReducer = (state = INITIAL_STATE, action) => { @@ -50,11 +51,12 @@ const scheduleReducer = (state = INITIAL_STATE, action) => { time_format } = payload; // data from JSON + const {customEventIds} = state; const filterByAccessLevel = only_events_with_attendee_access && isLoggedUser; const filterByMySchedule = is_my_schedule && isLoggedUser; const allFilteredEvents = preFilterEvents(all_events, pre_filters, summitTimeZoneId, userProfile, filterByAccessLevel, filterByMySchedule, hide_past_events_with_show_always_on_schedule); const newFilters = syncFilters(filters, state.filters); - const events = getFilteredEvents(allFilteredEvents, newFilters, summitTimeZoneId, hide_past_events_with_show_always_on_schedule); + const events = getFilteredEvents(allFilteredEvents, newFilters, summitTimeZoneId, hide_past_events_with_show_always_on_schedule, customEventIds); return { ...state, @@ -67,13 +69,15 @@ const scheduleReducer = (state = INITIAL_STATE, action) => { is_my_schedule, only_events_with_attendee_access, hide_past_events_with_show_always_on_schedule, - timeFormat: state.timeFormat || time_format || '12h' + timeFormat: state.timeFormat || time_format || '12h', + // preserved across reloads - not part of the API payload + customEventIds }; } case `SCHED_UPDATE_FILTER`: { const { type : filterType, values, hide_past_events_with_show_always_on_schedule } = payload; - const { filters, allEvents } = state; + const { filters, allEvents, customEventIds } = state; // update the filters with new values const newFilters = { ...filters, @@ -86,24 +90,24 @@ const scheduleReducer = (state = INITIAL_STATE, action) => { return {...state, filters : newFilters , // refilter events - events: getFilteredEvents(allEvents, newFilters, summitTimeZoneId, hide_past_events_with_show_always_on_schedule)} + events: getFilteredEvents(allEvents, newFilters, summitTimeZoneId, hide_past_events_with_show_always_on_schedule, customEventIds)} } case `SCHED_UPDATE_FILTERS`: { const {filters, view} = payload; - const {allEvents, hide_past_events_with_show_always_on_schedule} = state; - + const {allEvents, hide_past_events_with_show_always_on_schedule, customEventIds} = state; + // update events - const events = getFilteredEvents(allEvents, filters, summitTimeZoneId, hide_past_events_with_show_always_on_schedule); + const events = getFilteredEvents(allEvents, filters, summitTimeZoneId, hide_past_events_with_show_always_on_schedule, customEventIds); return {...state, filters, events, view} } case `SCHED_CLEAR_FILTERS`: { - const { allEvents, baseFilters, hide_past_events_with_show_always_on_schedule } = state; - + const { allEvents, baseFilters, hide_past_events_with_show_always_on_schedule, customEventIds } = state; + return {...state, filters : baseFilters , - // refilter events - events: getFilteredEvents(allEvents, baseFilters, summitTimeZoneId, hide_past_events_with_show_always_on_schedule)} + // refilter events - custom subset survives clear-filters, it is not a facet + events: getFilteredEvents(allEvents, baseFilters, summitTimeZoneId, hide_past_events_with_show_always_on_schedule, customEventIds)} } case `SCHED_CHANGE_VIEW`: { const {view} = payload; @@ -119,24 +123,32 @@ const scheduleReducer = (state = INITIAL_STATE, action) => { } case `SCHED_ADD_TO_SCHEDULE`: { const event = payload; - const {allEvents, filters, hide_past_events_with_show_always_on_schedule} = state; + const {allEvents, filters, hide_past_events_with_show_always_on_schedule, customEventIds} = state; allEvents.push(event); - const events = getFilteredEvents(allEvents, filters, summitTimeZoneId, hide_past_events_with_show_always_on_schedule); + const events = getFilteredEvents(allEvents, filters, summitTimeZoneId, hide_past_events_with_show_always_on_schedule, customEventIds); return {...state, allEvents, events}; } case `SCHED_REMOVE_FROM_SCHEDULE`: { const event = payload; - const {allEvents: allEventsCurrent, filters, hide_past_events_with_show_always_on_schedule} = state; + const {allEvents: allEventsCurrent, filters, hide_past_events_with_show_always_on_schedule, customEventIds} = state; const allEvents = allEventsCurrent.filter(ev => ev.id !== event.id); - const events = getFilteredEvents(allEvents, filters, summitTimeZoneId, hide_past_events_with_show_always_on_schedule); + const events = getFilteredEvents(allEvents, filters, summitTimeZoneId, hide_past_events_with_show_always_on_schedule, customEventIds); return {...state, allEvents, events}; } + case `SCHED_SET_CUSTOM_EVENT_IDS`: { + const {customEventIds} = payload; + const {allEvents, filters, hide_past_events_with_show_always_on_schedule} = state; + + const events = getFilteredEvents(allEvents, filters, summitTimeZoneId, hide_past_events_with_show_always_on_schedule, customEventIds); + + return {...state, customEventIds, events}; + } default: return state; } diff --git a/src/templates/__tests__/schedule-page.test.jsx b/src/templates/__tests__/schedule-page.test.jsx new file mode 100644 index 00000000..f388ab0a --- /dev/null +++ b/src/templates/__tests__/schedule-page.test.jsx @@ -0,0 +1,114 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; + +import SchedulePage from "../schedule-page"; +import { PHASES } from "../../utils/phasesUtils"; + +jest.mock("../../utils/withScheduleData", () => (Component) => Component); +jest.mock("../../actions/schedule-actions", () => ({ deepLinkToEvent: jest.fn() })); +// phasesUtils pulls in src/data/marketing-settings.json, a build-time artifact +// (gitignored, written by gatsby-node.js) that doesn't exist outside a real build. +jest.mock("../../utils/phasesUtils", () => ({ PHASES: { BEFORE: -1, DURING: 0, AFTER: 1 } })); + +jest.mock("gatsby", () => ({ + navigate: jest.fn(), + Link: ({ children, to }) => {children}, +})); + +jest.mock("../../components/Layout", () => ({ children }) =>
{children}
); +jest.mock("../../components/AttendanceTrackerComponent", () => () => null); +jest.mock("../../components/AttendeeToAttendeeWidgetComponent", () => () => null); +jest.mock("../../components/FilterButton", () => () => null); +jest.mock("../../pages/404", () => () =>
); + +jest.mock("../../components/FullSchedule", () => () =>
); +jest.mock("../../components/ScheduleFilters", () => (props) => ( +
ev.id).join(",")} /> +)); + +let mockCapturedScrollProps = null; +jest.mock("../../components/PageScrollInspector", () => { + const actual = jest.requireActual("../../components/PageScrollInspector"); + return { + ...actual, + PageScrollInspector: (props) => { + mockCapturedScrollProps = props; + return null; + }, + }; +}); + +const baseSchedule = { + key: "default", + filters: {}, + view: "list", + timezone: "UTC", + timeFormat: "12h", + colorSource: "type", +}; + +const defaultProps = { + summit: { id: 1 }, + schedKey: "default", + summitPhase: PHASES.DURING, + isLoggedUser: false, + location: { pathname: "/schedule" }, + colorSettings: {}, + updateFilter: jest.fn(), + clearFilters: jest.fn(), + callAction: jest.fn(), + scheduleProps: {}, + allowClick: true, + lastDataSync: null, +}; + +const renderSchedulePage = (scheduleState) => render(); + +beforeEach(() => { + mockCapturedScrollProps = null; + jest.clearAllMocks(); +}); + +describe("SchedulePage - custom event_ids subset", () => { + it("renders the full schedule and filters when there is no custom subset", () => { + renderSchedulePage({ + ...baseSchedule, + allEvents: [{ id: 1 }, { id: 2 }], + events: [{ id: 1 }, { id: 2 }], + customEventIds: [], + }); + + expect(screen.getByTestId("full-schedule")).toBeInTheDocument(); + expect(screen.getByTestId("schedule-filters")).toBeInTheDocument(); + expect(screen.queryByText(/no sessions match this link/i)).not.toBeInTheDocument(); + }); + + it("renders the empty-subset message and safely handles scroll callbacks when customEventIds matches no event", () => { + renderSchedulePage({ + ...baseSchedule, + allEvents: [{ id: 1 }, { id: 2 }], + events: [], + customEventIds: [999], + }); + + expect(screen.getByText(/no sessions match this link/i)).toBeInTheDocument(); + expect(screen.queryByTestId("full-schedule")).not.toBeInTheDocument(); + expect(screen.queryByTestId("schedule-filters")).not.toBeInTheDocument(); + + expect(mockCapturedScrollProps).not.toBeNull(); + expect(() => mockCapturedScrollProps.scrollDirectionChanged("SCROLL_UP")).not.toThrow(); + expect(() => mockCapturedScrollProps.bottomReached(true)).not.toThrow(); + }); + + it("scopes ScheduleFilters' allEvents to the subset and still renders the schedule when the subset matches", () => { + renderSchedulePage({ + ...baseSchedule, + allEvents: [{ id: 1 }, { id: 2 }, { id: 3 }], + events: [{ id: 1 }], + customEventIds: [1, 3], + }); + + expect(screen.getByTestId("full-schedule")).toBeInTheDocument(); + expect(screen.getByTestId("schedule-filters")).toHaveAttribute("data-event-ids", "1,3"); + }); +}); diff --git a/src/templates/schedule-page.js b/src/templates/schedule-page.js index 46f5b582..34a729fe 100644 --- a/src/templates/schedule-page.js +++ b/src/templates/schedule-page.js @@ -1,7 +1,7 @@ import React, { useEffect, useState, useCallback, useRef } from "react"; import PropTypes from "prop-types"; import { pickBy } from "lodash"; -import { navigate } from "gatsby"; +import { navigate, Link } from "gatsby"; import {deepLinkToEvent} from "../actions/schedule-actions"; import Layout from "../components/Layout"; import FullSchedule from "../components/FullSchedule"; @@ -19,7 +19,12 @@ const SchedulePage = ({ summit, scheduleState, summitPhase, isLoggedUser, locati const [showFilters, setShowfilters] = useState(false); const filtersWrapperRef = useRef(null); - const { key, events, allEvents, filters, view, timezone, timeFormat, colorSource } = scheduleState || {}; + const { key, events, allEvents, filters, view, timezone, timeFormat, colorSource, customEventIds } = scheduleState || {}; + const hasCustomSubset = customEventIds?.length > 0; + // the subset itself, ignoring any facet the user has applied on top - a facet narrowing + // to zero is the widget's normal empty state, not a reason to hide the filters + const subsetEvents = hasCustomSubset ? (allEvents || []).filter((ev) => customEventIds.includes(ev.id)) : null; + const subsetIsEmpty = hasCustomSubset && subsetEvents.length === 0; useEffect(() => { if (scheduleState && !!events?.length) { @@ -29,11 +34,11 @@ const SchedulePage = ({ summit, scheduleState, summitPhase, isLoggedUser, locati const onScrollDirectionChange = useCallback(direction => { if (direction === SCROLL_DIRECTION.UP) - filtersWrapperRef.current.scroll({ top: 0, behavior: 'smooth' }); + filtersWrapperRef.current?.scroll({ top: 0, behavior: 'smooth' }); }, [filtersWrapperRef]); const onPageBottomReached = useCallback(pageBottomReached => { - if (pageBottomReached) + if (pageBottomReached && filtersWrapperRef.current) filtersWrapperRef.current.scroll({ top: filtersWrapperRef.current.scrollHeight, behavior: 'smooth' }); }, [filtersWrapperRef]); @@ -47,7 +52,7 @@ const SchedulePage = ({ summit, scheduleState, summitPhase, isLoggedUser, locati const filterProps = { summit, events, - allEvents, + allEvents: hasCustomSubset ? subsetEvents : allEvents, filters: pickBy(filters, (value) => value.enabled), triggerAction: (action, payload) => { switch (action) { @@ -90,15 +95,26 @@ const SchedulePage = ({ summit, scheduleState, summitPhase, isLoggedUser, locati return (
-
-
- + {subsetIsEmpty ? ( +
+

No sessions match this link.

+ View the full schedule
-
- + ) : ( +
+
+ +
+
+ {/* schedule-filter-widget only builds its facet option list once, on mount, from + the allEvents prop it sees then - it never rebuilds it on later allEvents + changes. Keying on the custom subset forces a remount so the option list is + always rebuilt from the currently scoped allEvents. */} + +
+ setShowfilters(!showFilters)} />
- setShowfilters(!showFilters)} /> -
+ )}
diff --git a/src/utils/__test__/getFilteredEvents.test.js b/src/utils/__test__/getFilteredEvents.test.js new file mode 100644 index 00000000..5772c8d4 --- /dev/null +++ b/src/utils/__test__/getFilteredEvents.test.js @@ -0,0 +1,63 @@ +import { getFilteredEvents } from '../schedule'; + +const TIMEZONE = 'America/New_York'; + +const makeEvent = (id, overrides = {}) => ({ + id, + start_date: 1700000000, + end_date: 1700003600, + type: { show_always_on_schedule: false }, + track: { id: 1, track_groups: [] }, + speakers: [], + tags: [], + location: null, + title: `Event ${id}`, + description: `Description ${id}`, + ...overrides, +}); + +describe('getFilteredEvents - customEventIds (custom schedule subset)', () => { + const events = [makeEvent(1), makeEvent(2), makeEvent(3)]; + + it('returns only the events whose id is in the custom subset', () => { + const result = getFilteredEvents(events, {}, TIMEZONE, false, [1, 3]); + expect(result.map(e => e.id)).toEqual([1, 3]); + }); + + it('returns an empty list when no id in the subset matches any event', () => { + const result = getFilteredEvents(events, {}, TIMEZONE, false, [999]); + expect(result).toEqual([]); + }); + + it('returns all events when customEventIds is omitted (optional argument)', () => { + const result = getFilteredEvents(events, {}, TIMEZONE, false); + expect(result.map(e => e.id)).toEqual([1, 2, 3]); + }); + + it('ignores filters.event_ids entirely - only the explicit argument is honored', () => { + // characterisation of pre-existing behaviour: getFilteredEvents never read filters.event_ids, + // and it must keep not reading it now that a real event_ids mechanism exists. + const result = getFilteredEvents(events, { event_ids: { values: [1] } }, TIMEZONE, false); + expect(result.map(e => e.id)).toEqual([1, 2, 3]); + }); + + it('composes with a regular facet filter, narrowing within the subset', () => { + const eventsWithTracks = [ + makeEvent(1, { track: { id: 5, track_groups: [] } }), + makeEvent(2, { track: { id: 6, track_groups: [] } }), + makeEvent(3, { track: { id: 5, track_groups: [] } }), + ]; + const filters = { track: { values: ['5'] } }; + const result = getFilteredEvents(eventsWithTracks, filters, TIMEZONE, false, [1, 2]); + expect(result.map(e => e.id)).toEqual([1]); + }); + + it('still restricts events with show_always_on_schedule to the subset', () => { + const eventsAlwaysOn = [ + makeEvent(1, { type: { show_always_on_schedule: true } }), + makeEvent(2, { type: { show_always_on_schedule: true } }), + ]; + const result = getFilteredEvents(eventsAlwaysOn, {}, TIMEZONE, false, [1]); + expect(result.map(e => e.id)).toEqual([1]); + }); +}); diff --git a/src/utils/schedule.js b/src/utils/schedule.js index 66002e34..75c35db7 100644 --- a/src/utils/schedule.js +++ b/src/utils/schedule.js @@ -98,11 +98,17 @@ export const preFilterEvents = (events, filters, summitTimezone, userProfile, fi return getFilteredEvents(result, filters, summitTimezone, hidePast); }; -export const getFilteredEvents = (events, filters, summitTimezone, hidePast) => { +export const getFilteredEvents = (events, filters, summitTimezone, hidePast, customEventIds) => { const localNow = Date.now() / 1000; return events.filter((ev) => { let valid = true; + + if (customEventIds?.length > 0) { + valid = customEventIds.includes(ev.id); + if (!valid) return false; + } + if (filters.date?.values.length > 0) { const dateString = epochToMomentTimeZone( ev.start_date, diff --git a/src/utils/withScheduleData.js b/src/utils/withScheduleData.js index 59e9d9ab..51d5559d 100644 --- a/src/utils/withScheduleData.js +++ b/src/utils/withScheduleData.js @@ -3,14 +3,14 @@ import { connect } from "react-redux"; import { compose } from "redux"; import { useLocation } from '@reach/router'; import Interstitial from "../components/Interstitial"; -import { clearFilters, callAction, updateFilter, updateFiltersFromHash } from "../actions/schedule-actions"; +import { clearFilters, callAction, updateFilter, updateFiltersFromHash, updateCustomEventIdsFromHash } from "../actions/schedule-actions"; import { reloadScheduleData } from '../actions/base-actions'; // This HOC makes sure the schedules array in allSchedulesState is populated before render. const componentWrapper = (WrappedComponent) => ({schedules, ...props}) => { const [loaded, setLoaded] = useState(false); - const { updateFiltersFromHash, reloadScheduleData, schedKey, summit, staticJsonFilesBuildTime } = props; + const { updateFiltersFromHash, updateCustomEventIdsFromHash, reloadScheduleData, schedKey, summit, staticJsonFilesBuildTime } = props; const scheduleState = schedules?.find( s => s.key === schedKey); const { key, filters, view } = scheduleState || {}; const location = useLocation(); @@ -18,6 +18,7 @@ const componentWrapper = (WrappedComponent) => ({schedules, ...props}) => { useEffect(() => { if (schedules.length > 0) { updateFiltersFromHash(schedKey, filters, view); + updateCustomEventIdsFromHash(schedKey); setLoaded(true); } }, [key, location.hash]); @@ -46,6 +47,7 @@ const mapStateToProps = ({ const reduxConnection = connect(mapStateToProps, { updateFiltersFromHash, + updateCustomEventIdsFromHash, updateFilter, clearFilters, callAction,