From 34c3fd93fb35749165ba6bdb153d2ba1dc9104ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Thu, 13 Aug 2026 15:43:57 -0300 Subject: [PATCH 1/9] feat: allow to read event_ids from hash url to render a custom events schedule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/actions/schedule-actions.js | 14 ++ src/actions/tests/shareLinkHash.test.js | 107 +++++++++++++++ .../__tests__/scheduleReducer.test.js | 128 +++++++++++++++++- src/reducers/all-schedules-reducer.js | 5 +- src/reducers/schedule-reducer.js | 42 ++++-- src/templates/schedule-page.js | 36 +++-- src/utils/__test__/getFilteredEvents.test.js | 63 +++++++++ src/utils/schedule.js | 8 +- src/utils/withScheduleData.js | 6 +- 9 files changed, 376 insertions(+), 33 deletions(-) create mode 100644 src/actions/tests/shareLinkHash.test.js create mode 100644 src/utils/__test__/getFilteredEvents.test.js diff --git a/src/actions/schedule-actions.js b/src/actions/schedule-actions.js index ac6c9f24..ffdf86c1 100644 --- a/src/actions/schedule-actions.js +++ b/src/actions/schedule-actions.js @@ -9,6 +9,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 @@ -125,6 +126,19 @@ export const updateFiltersFromHash = } }; +export const updateCustomEventIdsFromHash = (key) => (dispatch) => { + const rawEventIds = fragmentParser.getParam("event_ids"); + + const customEventIds = rawEventIds + ? decodeURIComponent(rawEventIds) + .split(",") + .filter((val) => val !== "" && !isNaN(val)) + .map((val) => parseInt(val)) + : []; + + dispatch(createAction(SET_CUSTOM_EVENT_IDS)({ customEventIds, key })); +}; + export const getShareLink = (filters, view) => { const hashVars = {}; diff --git a/src/actions/tests/shareLinkHash.test.js b/src/actions/tests/shareLinkHash.test.js new file mode 100644 index 00000000..5c908df9 --- /dev/null +++ b/src/actions/tests/shareLinkHash.test.js @@ -0,0 +1,107 @@ +import { + updateCustomEventIdsFromHash, + updateFiltersFromHash, + getShareLink, + SET_CUSTOM_EVENT_IDS, +} from '../schedule-actions'; + +const setHash = (hash) => { + window.location.hash = hash; +}; + +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); + + 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)).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); + + 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); + + 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/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/schedule-page.js b/src/templates/schedule-page.js index 46f5b582..917acc4e 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) { @@ -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, From e347d79fa0b39f804b82ee1ce05345da7c3bb2c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Tue, 18 Aug 2026 16:15:48 -0300 Subject: [PATCH 2/9] fix: fix issue with filter params, validation for SET_CUSTOM_EVENT_IDS action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/actions/__tests__/scheduleActions.test.js | 46 +++++++++++++++++++ .../shareLinkHash.test.js | 0 src/actions/schedule-actions.js | 9 ++-- 3 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 src/actions/__tests__/scheduleActions.test.js rename src/actions/{tests => __tests__}/shareLinkHash.test.js (100%) 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 similarity index 100% rename from src/actions/tests/shareLinkHash.test.js rename to src/actions/__tests__/shareLinkHash.test.js diff --git a/src/actions/schedule-actions.js b/src/actions/schedule-actions.js index ffdf86c1..53aa166e 100644 --- a/src/actions/schedule-actions.js +++ b/src/actions/schedule-actions.js @@ -87,8 +87,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) => @@ -126,7 +125,7 @@ export const updateFiltersFromHash = } }; -export const updateCustomEventIdsFromHash = (key) => (dispatch) => { +export const updateCustomEventIdsFromHash = (key) => (dispatch, getState) => { const rawEventIds = fragmentParser.getParam("event_ids"); const customEventIds = rawEventIds @@ -136,6 +135,10 @@ export const updateCustomEventIdsFromHash = (key) => (dispatch) => { .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 })); }; From 28ecd25aac5eacc9ad9e9039bff9d2e6c35a10f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Tue, 18 Aug 2026 16:21:00 -0300 Subject: [PATCH 3/9] fix: mock getState on shareLinkHash test file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/actions/__tests__/shareLinkHash.test.js | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/actions/__tests__/shareLinkHash.test.js b/src/actions/__tests__/shareLinkHash.test.js index 5c908df9..2ee244d2 100644 --- a/src/actions/__tests__/shareLinkHash.test.js +++ b/src/actions/__tests__/shareLinkHash.test.js @@ -9,6 +9,12 @@ 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(''); }); @@ -18,7 +24,7 @@ describe('updateCustomEventIdsFromHash', () => { setHash('#event_ids=1,2,3'); const dispatch = jest.fn(); - updateCustomEventIdsFromHash('schedKey')(dispatch); + updateCustomEventIdsFromHash('schedKey')(dispatch, getState); expect(dispatch).toHaveBeenCalledWith({ type: SET_CUSTOM_EVENT_IDS, @@ -30,7 +36,7 @@ describe('updateCustomEventIdsFromHash', () => { setHash('#event_ids=1,abc,3'); const dispatch = jest.fn(); - expect(() => updateCustomEventIdsFromHash('schedKey')(dispatch)).not.toThrow(); + expect(() => updateCustomEventIdsFromHash('schedKey')(dispatch, getState)).not.toThrow(); expect(dispatch).toHaveBeenCalledWith({ type: SET_CUSTOM_EVENT_IDS, payload: { customEventIds: [1, 3], key: 'schedKey' }, @@ -41,7 +47,7 @@ describe('updateCustomEventIdsFromHash', () => { setHash('#track=5'); const dispatch = jest.fn(); - updateCustomEventIdsFromHash('schedKey')(dispatch); + updateCustomEventIdsFromHash('schedKey')(dispatch, getState); expect(dispatch).toHaveBeenCalledWith({ type: SET_CUSTOM_EVENT_IDS, @@ -53,7 +59,7 @@ describe('updateCustomEventIdsFromHash', () => { setHash('#EVENT_IDS=1,2'); const dispatch = jest.fn(); - updateCustomEventIdsFromHash('schedKey')(dispatch); + updateCustomEventIdsFromHash('schedKey')(dispatch, getState); expect(dispatch).toHaveBeenCalledWith({ type: SET_CUSTOM_EVENT_IDS, From 39f3b8efc597972cd1999a7ec02f1c20cf5fb9fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Wed, 19 Aug 2026 12:45:46 -0300 Subject: [PATCH 4/9] fix: add conditionals on scroll at schedule page, add try/catch for decode event_ids, add fallback value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/actions/schedule-actions.js | 17 +++++++++++------ src/templates/schedule-page.js | 4 ++-- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/actions/schedule-actions.js b/src/actions/schedule-actions.js index 53aa166e..1bf8f83d 100644 --- a/src/actions/schedule-actions.js +++ b/src/actions/schedule-actions.js @@ -128,12 +128,17 @@ export const updateFiltersFromHash = export const updateCustomEventIdsFromHash = (key) => (dispatch, getState) => { const rawEventIds = fragmentParser.getParam("event_ids"); - const customEventIds = rawEventIds - ? decodeURIComponent(rawEventIds) - .split(",") - .filter((val) => val !== "" && !isNaN(val)) - .map((val) => parseInt(val)) - : []; + let decoded = ""; + try { + decoded = rawEventIds ? decodeURIComponent(rawEventIds) : ""; + } catch { + 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 ?? []; diff --git a/src/templates/schedule-page.js b/src/templates/schedule-page.js index 917acc4e..d93dca60 100644 --- a/src/templates/schedule-page.js +++ b/src/templates/schedule-page.js @@ -34,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 && filtersWrappedRef.current) filtersWrapperRef.current.scroll({ top: filtersWrapperRef.current.scrollHeight, behavior: 'smooth' }); }, [filtersWrapperRef]); From 4f95af16984f4c7e6aba0323abe4b25e7302b7c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Wed, 19 Aug 2026 14:40:10 -0300 Subject: [PATCH 5/9] fix: fix typo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/templates/schedule-page.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/templates/schedule-page.js b/src/templates/schedule-page.js index d93dca60..34a729fe 100644 --- a/src/templates/schedule-page.js +++ b/src/templates/schedule-page.js @@ -38,7 +38,7 @@ const SchedulePage = ({ summit, scheduleState, summitPhase, isLoggedUser, locati }, [filtersWrapperRef]); const onPageBottomReached = useCallback(pageBottomReached => { - if (pageBottomReached && filtersWrappedRef.current) + if (pageBottomReached && filtersWrapperRef.current) filtersWrapperRef.current.scroll({ top: filtersWrapperRef.current.scrollHeight, behavior: 'smooth' }); }, [filtersWrapperRef]); From 37674cac544845d94a952f32b103767ccfe9830b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Wed, 19 Aug 2026 16:22:14 -0300 Subject: [PATCH 6/9] fix: add test for schedule page, add sentry capture message on catched decoded event_ids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/actions/schedule-actions.js | 4 +- .../__tests__/schedule-page.test.jsx | 144 ++++++++++++++++++ 2 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 src/templates/__tests__/schedule-page.test.jsx diff --git a/src/actions/schedule-actions.js b/src/actions/schedule-actions.js index 1bf8f83d..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"; @@ -131,7 +132,8 @@ export const updateCustomEventIdsFromHash = (key) => (dispatch, getState) => { let decoded = ""; try { decoded = rawEventIds ? decodeURIComponent(rawEventIds) : ""; - } catch { + } catch (e) { + Sentry?.captureMessage(`event_ids hash param is malformed and could not be decoded: ${rawEventIds}`); decoded = ""; } diff --git a/src/templates/__tests__/schedule-page.test.jsx b/src/templates/__tests__/schedule-page.test.jsx new file mode 100644 index 00000000..fd3ca947 --- /dev/null +++ b/src/templates/__tests__/schedule-page.test.jsx @@ -0,0 +1,144 @@ +/** + * @jest-environment jsdom + */ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { Provider } from "react-redux"; + +// Covers the schedule subset (event_ids) render branches added to schedule-page.js: +// the empty-subset message, the normal schedule+filters branch, and the scroll-ref +// guards that were previously unguarded and could crash once the filters wrapper +// stopped being unconditionally rendered. +import SchedulePage from "../schedule-page"; + +jest.mock("../../actions/schedule-actions", () => ({ + updateFiltersFromHash: jest.fn(() => ({ type: "TEST/NOOP" })), + updateCustomEventIdsFromHash: jest.fn(() => ({ type: "TEST/NOOP" })), + updateFilter: jest.fn(() => ({ type: "TEST/NOOP" })), + clearFilters: jest.fn(() => ({ type: "TEST/NOOP" })), + callAction: jest.fn(() => ({ type: "TEST/NOOP" })), + deepLinkToEvent: jest.fn(), +})); +jest.mock("../../actions/base-actions", () => ({ + reloadScheduleData: jest.fn(() => ({ type: "TEST/NOOP" })), +})); +// phasesUtils.js pulls in useMarketingSettings.js, which has a module-scope `graphql` +// tagged template - that's only stripped out by Gatsby's babel plugin at build time, +// so it breaks under plain jest unless the module is mocked out. +jest.mock("@utils/useMarketingSettings", () => ({ + MARKETING_SETTINGS_KEYS: { summitDeltaStartTime: "summit_delta_start_time" }, +})); +jest.mock( + "@reach/router", + () => ({ + useLocation: () => ({ pathname: "/a/schedule", hash: "" }), + }), + { virtual: true } +); +jest.mock("../../components/Layout", () => ({ children }) => <>{children}); +jest.mock("../../components/FullSchedule", () => () =>
); +jest.mock("../../components/ScheduleFilters", () => (props) => ( +
e.id).join(",")} /> +)); +jest.mock("../../components/AttendanceTrackerComponent", () => () => null); +jest.mock("../../components/AttendeeToAttendeeWidgetComponent", () => () => null); +jest.mock("../../components/FilterButton", () => () =>