Skip to content
46 changes: 46 additions & 0 deletions src/actions/__tests__/scheduleActions.test.js
Original file line number Diff line number Diff line change
@@ -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();
});
113 changes: 113 additions & 0 deletions src/actions/__tests__/shareLinkHash.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
28 changes: 26 additions & 2 deletions src/actions/schedule-actions.js
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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
Expand Down Expand Up @@ -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) =>
Expand Down Expand Up @@ -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 }));
Comment thread
tomrndom marked this conversation as resolved.
};

export const getShareLink = (filters, view) => {
const hashVars = {};

Expand Down
128 changes: 125 additions & 3 deletions src/reducers/__tests__/scheduleReducer.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
};

Expand All @@ -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', () => {
Expand All @@ -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
};

Expand All @@ -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']);
});

Expand All @@ -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']);
});

Expand All @@ -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
};

Expand Down Expand Up @@ -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');
});
});
Loading
Loading