Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 9 additions & 8 deletions src/actions/track-chair-actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
DEFAULT_PER_PAGE,
DOUBLE_PER_PAGE
} from "../utils/constants";
import { snackbarErrorHandler } from "./base-actions";

URI.escapeQuerySpace = false;

Expand Down Expand Up @@ -95,7 +96,7 @@ export const getTrackChairs =
expand: "member,categories",
relations: "member.none,categories.none",
fields:
"id,categories.id,categories.name,member.first_name,member.last_name,member.email"
"id,categories.id,categories.name,member.first_name,member.last_name,member.email,member.id"
};

if (filter.length > 0) {
Expand Down Expand Up @@ -125,9 +126,9 @@ export const getTrackChairs =
createAction(REQUEST_TRACK_CHAIRS),
createAction(RECEIVE_TRACK_CHAIRS),
`${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/track-chairs`,
authErrorHandler,
{ trackId, term, order, orderDir }
)(params)(dispatch).then(() => {
snackbarErrorHandler,
{ trackId, term, order, orderDir, perPage }
)(params)(dispatch).finally(() => {
dispatch(stopLoading());
});
};
Expand All @@ -150,7 +151,7 @@ export const addTrackChair =
createAction(TRACK_CHAIR_ADDED),
`${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/track-chairs`,
{ member_id: member.id, categories: trackIds },
authErrorHandler
snackbarErrorHandler
)(params)(dispatch).then(() => {
dispatch(stopLoading());
});
Expand All @@ -175,7 +176,7 @@ export const saveTrackChair =
`${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/track-chairs/${trackChairId}`,
{ categories: trackIds },
authErrorHandler
)(params)(dispatch).then(() => {
)(params)(dispatch).finally(() => {
dispatch(stopLoading());
});
};
Expand All @@ -195,8 +196,8 @@ export const deleteTrackChair =
createAction(TRACK_CHAIR_DELETED)({ trackChairId }),
`${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/track-chairs/${trackChairId}`,
null,
authErrorHandler
)(params)(dispatch).then(() => {
snackbarErrorHandler
)(params)(dispatch).finally(() => {
dispatch(stopLoading());
});
};
Expand Down
4 changes: 3 additions & 1 deletion src/components/mui/formik-inputs/mui-formik-async-select.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ const MuiFormikAsyncAutocomplete = ({
formatOption = (item) => ({ value: item.id.toString(), label: item.name }),
formatSelectedValue = null,
queryParams = [],
isMulti = false
isMulti = false,
...rest
}) => {
const [field, meta, helpers] = useField(name);
const [options, setOptions] = useState([]);
Expand Down Expand Up @@ -120,6 +121,7 @@ const MuiFormikAsyncAutocomplete = ({
{option.label}
</li>
)}
{...rest}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Risk: {...rest} can override critical internal props.

Spreading {...rest} after all other props means consumer-provided props like onChange, value, loading, or multiple can override the component's internal Formik-integrated handlers, breaking form synchronization silently.

Consider one of these safer patterns:

  1. Spread {...rest} before critical props so internal props take precedence.
  2. Explicitly allow only safe passthrough props (e.g., disabled, sx, className, testId).
🛡️ Recommended fix: spread rest before critical props
     />
   )}
-  {...rest}
+  disabled={disabled}
+  sx={sx}
+  className={className}
 />

Or if you need full flexibility, spread before:

   return (
     <Autocomplete
+      {...rest}
       options={options}
       value={value}
       onChange={handleChange}
       loading={loading}
       multiple={isMulti}
       ...
-      {...rest}
     />
   );

Note: If spreading before, document which props consumers can safely override.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/mui/formik-inputs/mui-formik-async-select.js` at line 124, The
spread operator `{...rest}` is positioned after critical internal props in the
component's prop list, allowing consumer-provided props to override essential
handlers like `onChange`, `value`, `loading`, and `multiple`, which breaks
Formik form synchronization. Move the `{...rest}` spread to appear before all
critical internal props (such as `onChange`, `value`, `loading`, `multiple`,
`onBlur`, etc.) so that the internal props always take precedence and cannot be
overridden by consumer input.

/>
);
};
Expand Down
2 changes: 1 addition & 1 deletion src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -3459,7 +3459,7 @@
"no_items": "No items found for this search criteria.",
"name": "Name",
"track": "Track",
"delete_warning": "Are you sure you want to remove track chair ",
"delete_warning": "Are you sure you want to remove track chair for",
"saved": "Track chair assigned successfully.",
"placeholders": {
"search": "Search track chairs by name",
Expand Down
207 changes: 207 additions & 0 deletions src/pages/track_chairs/__tests__/track-chair-list-page.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import React from "react";
import { act, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithRedux } from "utils/test-utils";
import {
saveTrackChair,
addTrackChair,
deleteTrackChair
} from "../../../actions/track-chair-actions";
import TrackChairListPage from "../track-chair-list-page";

jest.mock("../../../actions/track-chair-actions", () => ({
getTrackChairs: jest.fn(() => () => Promise.resolve()),
deleteTrackChair: jest.fn(() => () => Promise.resolve()),
saveTrackChair: jest.fn(() => () => Promise.resolve()),
addTrackChair: jest.fn(() => () => Promise.resolve()),
exportTrackChairs: jest.fn(() => () => Promise.resolve())
}));

jest.mock(
"openstack-uicore-foundation/lib/components/mui/table",
() =>
function MockMuiTable({ data, onEdit, onDelete }) {
return (
<div>
{data.map((item) => (
<div key={item.id}>
<button
data-testid={`edit-${item.id}`}
onClick={() => onEdit(item)}
>
edit-{item.id}
</button>
<button
data-testid={`delete-${item.id}`}
onClick={() => onDelete(item.id)}
>
delete-{item.id}
</button>
</div>
))}
</div>
);
}
);

jest.mock(
"openstack-uicore-foundation/lib/components/mui/search-input",
() => () => null
);

// Capture the dialog's props so tests can call onSave/onClose with specific values
let capturedDialogProps = null;
jest.mock("../components/track-chair-dialog", () => ({
__esModule: true,
default: (props) => {
capturedDialogProps = props;
return <div data-testid="track-chair-dialog" />;
}
}));

const mockTracks = [
{ id: 1, name: "Track A", chair_visible: true },
{ id: 2, name: "Track B", chair_visible: true }
];

const mockTrackChair = {
id: 10,
name: "Jane Doe (jane@example.com)",
member: {
id: 42,
first_name: "Jane",
last_name: "Doe",
email: "jane@example.com"
},
categories: [{ id: 1, name: "Track A" }],
trackIds: [1],
trackNames: "Track A"
};

const initialState = {
currentSummitState: {
currentSummit: { id: 1, name: "Test Summit", tracks: mockTracks }
},
trackChairListState: {
trackChairs: [],
trackId: null,
term: "",
order: "id",
orderDir: 1,
currentPage: 1,
lastPage: 1,
perPage: 10,
totalTrackChairs: 0
}
};

describe("TrackChairListPage", () => {
beforeEach(() => {
jest.clearAllMocks();
capturedDialogProps = null;
});

const renderPage = (stateOverrides = {}) =>
renderWithRedux(<TrackChairListPage history={{ push: jest.fn() }} />, {
initialState: {
...initialState,
trackChairListState: {
...initialState.trackChairListState,
...stateOverrides
}
}
});

describe("dialog visibility", () => {
it("shows after clicking Add and closes when onClose is called", async () => {
renderPage();
expect(
screen.queryByTestId("track-chair-dialog")
).not.toBeInTheDocument();

await act(async () => {
await userEvent.click(screen.getByRole("button", { name: /add/i }));
});
expect(screen.getByTestId("track-chair-dialog")).toBeInTheDocument();

await act(async () => {
capturedDialogProps.onClose();
});
expect(
screen.queryByTestId("track-chair-dialog")
).not.toBeInTheDocument();
});
});

describe("handleEdit", () => {
it("passes the correct entity shape to the dialog", async () => {
renderPage({ trackChairs: [mockTrackChair] });

await act(async () => {
await userEvent.click(screen.getByTestId(`edit-${mockTrackChair.id}`));
});

expect(capturedDialogProps.entity).toEqual({
id: mockTrackChair.id,
member: mockTrackChair.member,
originalMemberId: mockTrackChair.member.id,
trackIds: [1] // mapped from categories
});
});
});

describe("handleDelete", () => {
it("calls deleteTrackChair with the row id", async () => {
renderPage({ trackChairs: [mockTrackChair] });

await act(async () => {
await userEvent.click(
screen.getByTestId(`delete-${mockTrackChair.id}`)
);
});

expect(deleteTrackChair).toHaveBeenCalledWith(mockTrackChair.id);
});
});

describe("handleSave", () => {
it("calls addTrackChair when saving a new chair (no id)", async () => {
renderPage();

await act(async () => {
await userEvent.click(screen.getByRole("button", { name: /add/i }));
});

await act(async () => {
await capturedDialogProps.onSave({
id: 0,
member: { value: 99, label: "New Member" },
trackIds: [1]
});
});

expect(addTrackChair).toHaveBeenCalledWith({ id: 99 }, [1]);
expect(saveTrackChair).not.toHaveBeenCalled();
});

it("calls saveTrackChair when only tracks change on an existing chair", async () => {
renderPage({ trackChairs: [mockTrackChair] });

await act(async () => {
await userEvent.click(screen.getByTestId(`edit-${mockTrackChair.id}`));
});

// Same member (value: 42 === originalMemberId: 42), different tracks
await act(async () => {
await capturedDialogProps.onSave({
id: mockTrackChair.id,
member: { value: 42 },
trackIds: [1, 2]
});
});

expect(saveTrackChair).toHaveBeenCalledWith(mockTrackChair.id, [1, 2]);
expect(addTrackChair).not.toHaveBeenCalled();
});
});
});
Loading
Loading