diff --git a/src/actions/selection-plan-actions.js b/src/actions/selection-plan-actions.js index 3c24ec0fb..baba7dd7a 100644 --- a/src/actions/selection-plan-actions.js +++ b/src/actions/selection-plan-actions.js @@ -589,8 +589,10 @@ export const deleteSelectionPlanExtraQuestionValue = /** ********************* EVENT TYPES ****************************************** */ -export const EVENT_TYPE_ADDED = "EVENT_TYPE_ADDED"; -export const EVENT_TYPE_REMOVED = "EVENT_TYPE_REMOVED"; +export const SELECTION_PLAN_EVENT_TYPE_ADDED = + "SELECTION_PLAN_EVENT_TYPE_ADDED"; +export const SELECTION_PLAN_EVENT_TYPE_REMOVED = + "SELECTION_PLAN_EVENT_TYPE_REMOVED"; export const addEventTypeSelectionPlan = (selectionPlanId, eventType) => async (dispatch, getState) => { @@ -606,7 +608,7 @@ export const addEventTypeSelectionPlan = return putRequest( null, - createAction(EVENT_TYPE_ADDED)({ eventType }), + createAction(SELECTION_PLAN_EVENT_TYPE_ADDED)({ eventType }), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/selection-plans/${selectionPlanId}/event-types/${eventType.id}`, {}, authErrorHandler @@ -628,7 +630,7 @@ export const deleteEventTypeSelectionPlan = return deleteRequest( null, - createAction(EVENT_TYPE_REMOVED)({ eventTypeId }), + createAction(SELECTION_PLAN_EVENT_TYPE_REMOVED)({ eventTypeId }), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/selection-plans/${selectionPlanId}/event-types/${eventTypeId}`, null, authErrorHandler diff --git a/src/pages/events/summit-event-list-page.js b/src/pages/events/summit-event-list-page.js index bf67fe11c..65ed47e00 100644 --- a/src/pages/events/summit-event-list-page.js +++ b/src/pages/events/summit-event-list-page.js @@ -52,8 +52,7 @@ import { DEFAULT_CURRENT_PAGE, DEFAULT_PER_PAGE, DEFAULT_Z_INDEX, - HIGH_Z_INDEX, - INDEX_NOT_FOUND + HIGH_Z_INDEX } from "../../utils/constants"; import { defaultColumns, @@ -68,6 +67,7 @@ import { } from "../../actions/filter-criteria-actions"; import { CONTEXT_ACTIVITIES } from "../../utils/filter-criteria-constants"; import EditableTable from "../../components/tables/editable-table/EditableTable"; +import { buildNameIdDDL } from "../../utils/events/summit-event-list-page.utils"; const fieldNames = (allSelectionPlans, allTracks, event_types) => [ { @@ -113,9 +113,7 @@ const fieldNames = (allSelectionPlans, allTracks, event_types) => [ value: "track", sortable: true, editableField: (extraProps) => { - const track_ddl = allTracks - ?.sort((a, b) => a.order - b.order) - .map((t) => ({ label: t.name, value: t.id })); + const track_ddl = buildNameIdDDL(allTracks); return ( [ value: "selection_plan", sortable: true, editableField: (extraProps) => { - if (!extraProps.row.type?.id) return false; - - const event_type = event_types.find( - (t) => t.id === extraProps.row.type?.id - ); + const isValid = (obj, keys) => + keys.every((k) => obj && typeof obj[k] !== "undefined"); + const isValidSP = (sp) => + sp && + typeof sp.id !== "undefined" && + typeof sp.name === "string" && + sp.name.trim(); + + if (!extraProps.row?.type?.id) return false; + const event_type = Array.isArray(event_types) + ? event_types.find( + (t) => isValid(t, ["id"]) && t.id === extraProps.row.type?.id + ) + : null; + if (!event_type) return false; const allowSelectionPlanEdit = - ["PresentationType"].indexOf(event_type.class_name) !== - INDEX_NOT_FOUND || - ["PresentationType"].indexOf(event_type.name) !== INDEX_NOT_FOUND; - + ["PresentationType"].includes(event_type.class_name) || + ["PresentationType"].includes(event_type.name); if (!allowSelectionPlanEdit) return false; - const track = allTracks.find((t) => t.id === extraProps.row?.track?.id); - - const selection_plans_per_track = allSelectionPlans - .filter( - (sp) => - !track || - sp.track_groups.some((gr) => track.track_groups.includes(gr)) - ) - ?.sort((a, b) => a.order - b.order) - .map((sp) => ({ label: sp.name, value: sp.id })); + const trackId = extraProps.row?.track?.id; + const track = + trackId !== undefined && trackId !== null + ? allTracks.find((t) => isValid(t, ["id"]) && t.id === trackId) + : null; + + const selection_plans_per_track = buildNameIdDDL( + (Array.isArray(allSelectionPlans) ? allSelectionPlans : []) + .filter(isValidSP) + .filter( + (sp) => + !track || + (Array.isArray(sp.track_groups) && + Array.isArray(track.track_groups) && + sp.track_groups.some((gr) => track.track_groups.includes(gr))) + ) + ); return ( a.order - b.order) - .map((sp) => ({ label: sp.name, value: sp.id })); + const selection_plans_ddl = buildNameIdDDL(currentSummit.selection_plans); - const location_ddl = currentSummit.locations - ?.sort((a, b) => a.order - b.order) - .map((l) => ({ label: l.name, value: l.id })); + const location_ddl = buildNameIdDDL(currentSummit.locations); const selection_status_ddl = [ { label: "Pending", value: "pending" }, @@ -1028,13 +1037,9 @@ class SummitEventListPage extends React.Component { { label: "Alternate", value: "alternate" } ]; - const track_ddl = currentSummit.tracks - ?.sort((a, b) => a.order - b.order) - .map((t) => ({ label: t.name, value: t.id })); + const track_ddl = buildNameIdDDL(currentSummit.tracks); - const event_type_ddl = currentSummit.event_types - ?.sort((a, b) => a.order - b.order) - .map((t) => ({ label: t.name, value: t.id })); + const event_type_ddl = buildNameIdDDL(currentSummit.event_types); const level_ddl = [ { label: "Beginner", value: "beginner" }, diff --git a/src/reducers/selection_plans/__tests__/selection-plan-reducer.test.js b/src/reducers/selection_plans/__tests__/selection-plan-reducer.test.js new file mode 100644 index 000000000..826b411c0 --- /dev/null +++ b/src/reducers/selection_plans/__tests__/selection-plan-reducer.test.js @@ -0,0 +1,33 @@ +import selectionPlanReducer from "../selection-plan-reducer"; +import { SELECTION_PLAN_EVENT_TYPE_ADDED } from "../../../actions/selection-plan-actions"; + +describe("SelectionPlanReducer", () => { + describe("SELECTION_PLAN_EVENT_TYPE_ADDED", () => { + test("should append event type for selection plan event type added action", () => { + const initialState = { + entity: { + id: 1, + event_types: [{ id: 1, name: "Talk" }], + track_groups: [], + extra_questions: [], + allowed_presentation_action_types: [], + track_chair_rating_types: [], + marketing_settings: {} + }, + allowedMembers: { data: [], currentPage: 1, lastPage: 1 }, + errors: {} + }; + + const eventType = { id: 2, name: "Workshop" }; + const result = selectionPlanReducer(initialState, { + type: SELECTION_PLAN_EVENT_TYPE_ADDED, + payload: { eventType } + }); + + expect(result.entity.event_types).toStrictEqual([ + { id: 1, name: "Talk" }, + { id: 2, name: "Workshop" } + ]); + }); + }); +}); diff --git a/src/reducers/selection_plans/selection-plan-reducer.js b/src/reducers/selection_plans/selection-plan-reducer.js index 89a96b078..caca7f551 100644 --- a/src/reducers/selection_plans/selection-plan-reducer.js +++ b/src/reducers/selection_plans/selection-plan-reducer.js @@ -9,8 +9,8 @@ import { SELECTION_PLAN_ADDED, TRACK_GROUP_REMOVED, TRACK_GROUP_ADDED, - EVENT_TYPE_ADDED, - EVENT_TYPE_REMOVED, + SELECTION_PLAN_EVENT_TYPE_ADDED, + SELECTION_PLAN_EVENT_TYPE_REMOVED, SELECTION_PLAN_EXTRA_QUESTION_ADDED, SELECTION_PLAN_EXTRA_QUESTION_DELETED, SELECTION_PLAN_EXTRA_QUESTION_UPDATED, @@ -123,9 +123,8 @@ const selectionPlanReducer = (state = DEFAULT_STATE, action) => { // we need this in case the token expired while editing the form if (payload.hasOwnProperty("persistStore")) { return state; - } - return { ...state, entity: { ...DEFAULT_ENTITY }, errors: {} }; - + } + return { ...state, entity: { ...DEFAULT_ENTITY }, errors: {} }; } case SET_CURRENT_SUMMIT: case RESET_SELECTION_PLAN_FORM: { @@ -176,10 +175,10 @@ const selectionPlanReducer = (state = DEFAULT_STATE, action) => { } case RECEIVE_SELECTION_PLAN_PROGRESS_FLAGS: { const progressFlags = payload.response.data.map((r) => ({ - id: r.id, - label: r.label, - order: parseInt(r.order) - })); + id: r.id, + label: r.label, + order: parseInt(r.order) + })); return { ...state, entity: { @@ -203,10 +202,10 @@ const selectionPlanReducer = (state = DEFAULT_STATE, action) => { } case SELECTION_PLAN_PROGRESS_FLAG_ORDER_UPDATED: { const progressFlags = payload.map((r) => ({ - id: r.id, - label: r.label, - order: parseInt(r.order) - })); + id: r.id, + label: r.label, + order: parseInt(r.order) + })); return { ...state, entity: { @@ -249,14 +248,14 @@ const selectionPlanReducer = (state = DEFAULT_STATE, action) => { } }; } - case EVENT_TYPE_REMOVED: { + case SELECTION_PLAN_EVENT_TYPE_REMOVED: { const { eventTypeId } = payload; const eventTypes = state.entity.event_types.filter( (t) => t.id !== eventTypeId ); return { ...state, entity: { ...state.entity, event_types: eventTypes } }; } - case EVENT_TYPE_ADDED: { + case SELECTION_PLAN_EVENT_TYPE_ADDED: { const eventType = { ...payload.eventType }; return { ...state, @@ -307,12 +306,12 @@ const selectionPlanReducer = (state = DEFAULT_STATE, action) => { case SELECTION_PLAN_EXTRA_QUESTION_ORDER_UPDATED: { const extra_questions = payload.map((q, i) => ({ - id: q.id, - name: q.name, - label: q.label, - type: q.type, - order: i + 1 - })); + id: q.id, + name: q.name, + label: q.label, + type: q.type, + order: i + 1 + })); return { ...state, @@ -357,11 +356,11 @@ const selectionPlanReducer = (state = DEFAULT_STATE, action) => { } case SELECTION_PLAN_RATING_TYPE_ORDER_UPDATED: { const track_chair_rating_types = payload.map((r) => ({ - id: r.id, - name: r.name, - weight: parseFloat(r.weight), - order: parseInt(r.order) - })); + id: r.id, + name: r.name, + weight: parseFloat(r.weight), + order: parseInt(r.order) + })); return { ...state, entity: { @@ -396,7 +395,7 @@ const selectionPlanReducer = (state = DEFAULT_STATE, action) => { return { ...state, errors: payload.errors }; } case RECEIVE_SELECTION_PLAN_SETTINGS: { - const {data} = payload.response; + const { data } = payload.response; // parse data const settings = data.map((ms) => ({ [ms.key.toLowerCase()]: { diff --git a/src/reducers/summits/__tests__/current-summit-reducer.test.js b/src/reducers/summits/__tests__/current-summit-reducer.test.js new file mode 100644 index 000000000..600125c93 --- /dev/null +++ b/src/reducers/summits/__tests__/current-summit-reducer.test.js @@ -0,0 +1,50 @@ +import currentSummitReducer, { DEFAULT_STATE } from "../current-summit-reducer"; +import { EVENT_TYPE_ADDED } from "../../../actions/event-type-actions"; +import { SELECTION_PLAN_EVENT_TYPE_ADDED } from "../../../actions/selection-plan-actions"; + +describe("CurrentSummitReducer", () => { + describe("SELECTION_PLAN_EVENT_TYPE_ADDED", () => { + test("should ignore selection plan event type added action", () => { + const initialState = { + ...DEFAULT_STATE, + currentSummit: { + ...DEFAULT_STATE.currentSummit, + event_types: [{ id: 1, name: "Talk" }] + } + }; + + const result = currentSummitReducer(initialState, { + type: SELECTION_PLAN_EVENT_TYPE_ADDED, + payload: { eventType: { id: 2, name: "Workshop" } } + }); + + expect(result).toBe(initialState); + expect(result.currentSummit.event_types).toStrictEqual([ + { id: 1, name: "Talk" } + ]); + }); + }); + + describe("EVENT_TYPE_ADDED", () => { + test("should append event type for summit event type added action", () => { + const initialState = { + ...DEFAULT_STATE, + currentSummit: { + ...DEFAULT_STATE.currentSummit, + event_types: [{ id: 1, name: "Talk" }] + } + }; + + const response = { id: 2, name: "Workshop" }; + const result = currentSummitReducer(initialState, { + type: EVENT_TYPE_ADDED, + payload: { response } + }); + + expect(result.currentSummit.event_types).toStrictEqual([ + { id: 1, name: "Talk" }, + { id: 2, name: "Workshop" } + ]); + }); + }); +}); diff --git a/src/utils/events/__tests__/summit-event-list-page.utils.test.js b/src/utils/events/__tests__/summit-event-list-page.utils.test.js new file mode 100644 index 000000000..6b369217f --- /dev/null +++ b/src/utils/events/__tests__/summit-event-list-page.utils.test.js @@ -0,0 +1,78 @@ +/* eslint-env jest */ + +import { buildNameIdDDL, sortByOrder } from "../summit-event-list-page.utils"; + +describe("summit-event-list-page utils", () => { + test("sortByOrder pushes missing order to the end", () => { + const items = [ + { order: 10 }, + {}, + { order: "2" }, + { order: "" }, + { order: null } + ]; + const sorted = items.slice().sort(sortByOrder); + + expect(sorted).toEqual([ + { order: "2" }, + { order: 10 }, + {}, + { order: "" }, + { order: null } + ]); + }); + + test("buildNameIdDDL returns empty array for non-array values", () => { + expect(buildNameIdDDL(null)).toEqual([]); + expect(buildNameIdDDL(undefined)).toEqual([]); + expect(buildNameIdDDL({})).toEqual([]); + }); + + test("buildNameIdDDL returns a stable reference for the same input array", () => { + const source = [ + { id: 1, name: "One", order: 2 }, + { id: 2, name: "Two", order: 1 } + ]; + + const firstResult = buildNameIdDDL(source); + const secondResult = buildNameIdDDL(source); + + expect(secondResult).toBe(firstResult); + }); + + test("buildNameIdDDL filters invalid records and maps valid ones", () => { + const result = buildNameIdDDL([ + undefined, + null, + { id: null, name: "Invalid" }, + { id: 1 }, + { id: 9, name: " " }, + { name: "Missing id" }, + { id: 2, name: "Valid" }, + { id: 0, name: "Zero id is valid" } + ]); + + expect(result).toEqual([ + { label: "Valid", value: 2 }, + { label: "Zero id is valid", value: 0 } + ]); + }); + + test("buildNameIdDDL sorts by order without mutating input", () => { + const source = [ + { id: 1, name: "Last", order: 3 }, + { id: 2, name: "First", order: 1 }, + { id: 3, name: "No order" } + ]; + const originalSnapshot = source.map((item) => ({ ...item })); + + const result = buildNameIdDDL(source); + + expect(result).toEqual([ + { label: "First", value: 2 }, + { label: "Last", value: 1 }, + { label: "No order", value: 3 } + ]); + expect(source).toEqual(originalSnapshot); + }); +}); diff --git a/src/utils/events/summit-event-list-page.utils.js b/src/utils/events/summit-event-list-page.utils.js new file mode 100644 index 000000000..352632851 --- /dev/null +++ b/src/utils/events/summit-event-list-page.utils.js @@ -0,0 +1,41 @@ +export const sortByOrder = (a, b) => { + const getOrderValue = (value) => { + if (value === null || value === undefined || value === "") { + return Number.MAX_SAFE_INTEGER; + } + + const numericOrder = Number(value); + return Number.isFinite(numericOrder) + ? numericOrder + : Number.MAX_SAFE_INTEGER; + }; + + const leftOrder = getOrderValue(a?.order); + const rightOrder = getOrderValue(b?.order); + + return leftOrder - rightOrder; +}; + +const EMPTY_DDL = []; +const ddlCache = new WeakMap(); + +export const buildNameIdDDL = (items) => { + if (!Array.isArray(items)) return EMPTY_DDL; + + const cached = ddlCache.get(items); + if (cached) return cached; + + const result = items + .filter( + (item) => + item?.id !== undefined && + item?.id !== null && + typeof item?.name === "string" && + item.name.trim().length > 0 + ) + .sort(sortByOrder) + .map((item) => ({ label: item.name, value: item.id })); + + ddlCache.set(items, result); + return result; +};