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 }) =>
No sessions match this link.
+ View the full schedule