Skip to content
Open
5 changes: 3 additions & 2 deletions src/actions/sponsor-actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import {
fetchErrorHandler,
postFile
} from "openstack-uicore-foundation/lib/utils/actions";
import debounce from "lodash/debounce"
import debounce from "lodash/debounce";
import URI from "urijs";
import { getAccessTokenSafely } from "../utils/methods";
import { normalizeLeadReportSettings } from "../models/lead-report-settings";
Expand Down Expand Up @@ -195,7 +195,8 @@ export const querySponsors = debounce(async (input, summitId, callback) => {
.then((json) => {
const options = [...json.data].map((sp) => ({
id: sp.id,
name: sp.company.name
name: sp.company.name,
companyId: sp.company.id
}));
callback(options);
})
Expand Down
83 changes: 40 additions & 43 deletions src/actions/sponsor-users-actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ import {
postRequest,
putRequest,
startLoading,
stopLoading
stopLoading,
snackbarErrorHandler,
snackbarErrorMsg,
snackbarSuccessHandler
} from "openstack-uicore-foundation/lib/utils/actions";
import T from "i18n-react/dist/i18n-react";
import { escapeFilterValue, getAccessTokenSafely } from "../utils/methods";
Expand All @@ -33,11 +36,6 @@ import {
IMPORT_SPONSOR_USERS_STATUS,
SPONSOR_USER_ASSIGNMENT_TYPE
} from "../utils/constants";
import {
snackbarErrorHandler,
snackbarErrorMsg,
snackbarSuccessHandler
} from "./base-actions";

export const RECEIVE_SPONSOR_USER_GROUPS = "RECEIVE_SPONSOR_USER_GROUPS";
export const REQUEST_SPONSOR_USER_REQUESTS = "REQUEST_SPONSOR_USER_REQUESTS";
Expand Down Expand Up @@ -504,46 +502,46 @@ export const deleteSponsorUser =

/** **************** SPONSOR USERS TAB *************************************** */

export const sendSponsorUserInvite = (email) => async (dispatch, getState) => {
const { currentSummitState, currentSponsorState } = getState();
const accessToken = await getAccessTokenSafely();
const { currentSummit } = currentSummitState;
const { entity } = currentSponsorState;
export const sendSponsorUserInvite =
(email, sponsorId = null) =>
async (dispatch, getState) => {
const { currentSummitState, currentSponsorState } = getState();
const accessToken = await getAccessTokenSafely();
const { currentSummit } = currentSummitState;
const { entity } = currentSponsorState;

const params = {
access_token: accessToken
};
const params = {
access_token: accessToken
};

dispatch(startLoading());
dispatch(startLoading());

const payload = {
user_email: email,
sponsor_id: entity.id,
summit_id: currentSummit.id
};
const payload = {
user_email: email,
sponsor_id: sponsorId || entity.id,
summit_id: currentSummit.id
};

return postRequest(
null,
createAction(DUMMY_ACTION),
`${window.SPONSOR_USERS_API_URL}/api/v1/sponsor-users`,
payload,
snackbarErrorHandler,
entity
)(params)(dispatch)
.then(() => {
dispatch(stopLoading());
dispatch(
snackbarSuccessHandler({
title: T.translate("general.success"),
html: T.translate("sponsor_users.new_user.success")
})
);
})
.catch(console.log) // need to catch promise reject
.finally(() => {
dispatch(stopLoading());
});
};
return postRequest(
null,
createAction(DUMMY_ACTION),
`${window.SPONSOR_USERS_API_URL}/api/v1/sponsor-users`,
payload,
snackbarErrorHandler,
entity
)(params)(dispatch)
Comment on lines +525 to +532

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@tomrndom Confirmed. I traced postRequest's internal handler (oe in the minified openstack-uicore-foundation bundle) — on error it dispatches the error handler and calls reject(...), so removing the .catch(console.log) here does make the returned promise reject on failure where it previously always resolved. new-user-popup.js:38 (sendSponsorUserInvite(values.email).finally(...)) has no .catch(), so a failed invite from that (untouched) screen will now surface as an unhandled promise rejection. Needs a .catch() added there (or a rethrow-and-log wrapper kept in the action) as part of this PR, since it's the one changing the contract.

.then(() => {
dispatch(
snackbarSuccessHandler({
title: T.translate("general.success"),
html: T.translate("sponsor_users.new_user.success")
})
);
})
.finally(() => {
dispatch(stopLoading());
});
Comment thread
smarcet marked this conversation as resolved.
};

export const fetchSponsorUsersBySummit = async (
currentSummitId,
Expand Down Expand Up @@ -652,7 +650,6 @@ export const importSponsorUsers =
})
);
})
.catch(console.log) // need to catch promise reject
.finally(() => {
dispatch(stopLoading());
});
Comment thread
smarcet marked this conversation as resolved.
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2963,7 +2963,8 @@
"import_users": "Import Users",
"running": "Users import running on background.",
"success": "Users imported successfully.",
"fail": "Import failed"
"fail": "Import failed",
"fetch_users_fail": "There was an error fetching the users list."
}
},
"sponsorship_list": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,14 +68,11 @@ const ImportUsersPopup = ({
};

const handleImport = async () => {
importSponsorUsers(
sponsorId,
companyId,
selectedSummit,
selectedUsers
).then(() => {
onClose();
});
importSponsorUsers(sponsorId, companyId, selectedSummit, selectedUsers)
.then(() => {
onClose();
})
.catch(() => {});
};

const handleSelectOnChange = (items, all = false) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,12 @@ const NewUserPopup = ({
};

const handleOnSave = (values) => {
sendSponsorUserInvite(values.email).finally(() => {
getSponsorUsers(sponsorId);
handleClose();
});
sendSponsorUserInvite(values.email)
.catch(() => {})
.finally(() => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@tomrndom This handler closes the dialog and refreshes the list unconditionally via .finally(), even when sendSponsorUserInvite rejects (duplicate email, validation error, etc.) - the typed email is lost with no chance to correct and retry.

This is inconsistent with the sibling popup added in this same PR, sponsor-global-new-user-popup.js, which gates the close/refresh on success only:

// sponsor-global-new-user-popup.js
sendSponsorUserInvite(values.email, values.sponsor.id)
  .then(() => {
    getSponsorUsers();
    handleClose(); // only runs on success
  })
  .catch(() => {})
  .finally(() => setIsSaving(false));

vs. this file:

// new-user-popup.js
sendSponsorUserInvite(values.email)
  .catch(() => {})
  .finally(() => {
    getSponsorUsers(sponsorId);
    handleClose(); // runs on success AND failure
  });

Both call the same action, which now genuinely rejects on failure since this PR removed its internal catch(console.log). Moving getSponsorUsers/handleClose into the .then() here (matching the new popup) would fix it and align with the documented pattern: https://github.com/fntechgit/ftn-docsnsklz/blob/main/patterns/show-admin/summit-admin-popup-dialog-pattern.md - "Rejected saves keep the dialog open and re-enable controls."

getSponsorUsers(sponsorId);
handleClose();
});
};

const formik = useFormik({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import React from "react";
import { act, render } from "@testing-library/react";
import SponsorUsersListPage from "../index";
import { renderWithRedux } from "../../../../utils/test-utils";
import {
getSponsorUserRequests,
getSponsorUsers,
trackImportSponsorUsers
} from "../../../../actions/sponsor-users-actions";
import { TEN_SECONDS_IN_MILLISECONDS } from "../../../../utils/constants";

// ── Mocks ──────────────────────────────────────────────────────────────────────

jest.mock("i18n-react/dist/i18n-react", () => ({
translate: (key) => key
}));

jest.mock("../../../../actions/sponsor-users-actions", () => ({
getSponsorUsers: jest.fn(() => ({ type: "MOCK_ACTION" })),
getSponsorUserRequests: jest.fn(() => ({ type: "MOCK_ACTION" })),
deleteSponsorUser: jest.fn(() => ({ type: "MOCK_ACTION" })),
deleteSponsorUserRequest: jest.fn(() => ({ type: "MOCK_ACTION" })),
trackImportSponsorUsers: jest.fn(() => ({ type: "MOCK_ACTION" }))
}));

jest.mock("react-breadcrumbs", () => ({
Breadcrumb: () => <div data-testid="breadcrumb" />
}));

jest.mock(
"openstack-uicore-foundation/lib/components/mui/search-input",
() =>
function SearchInputMock() {
return <div data-testid="search-input" />;
}
);

jest.mock("../components/request-table", () => () => (
<div data-testid="request-table" />
));

jest.mock("../components/users-table", () => () => (
<div data-testid="users-table" />
));

jest.mock("../components/sponsor-global-new-user-popup", () => () => (
<div data-testid="new-user-popup" />
));

jest.mock("../components/sponsor-global-import-users-popup", () => () => (
<div data-testid="import-users-popup" />
));

jest.mock("../components/edit-user-popup", () => () => (
<div data-testid="edit-user-popup" />
));

// ── Helpers ────────────────────────────────────────────────────────────────────

const DEFAULT_USERS = {
items: [],
order: "id",
orderDir: 1,
currentPage: 1,
lastPage: 1,
perPage: 10,
totalCount: 0
};

const DEFAULT_REQUESTS = {
items: [],
order: "id",
orderDir: 1,
currentPage: 1,
lastPage: 1,
perPage: 10,
totalCount: 0
};

const buildState = ({
summitId = 1,
importTasks = [],
requests = DEFAULT_REQUESTS,
users = DEFAULT_USERS,
term = ""
} = {}) => ({
currentSummitState: { currentSummit: { id: summitId } },
sponsorUsersListState: { term, userGroups: [], importTasks, requests, users }
});

const renderPage = (stateOverrides = {}) =>
renderWithRedux(<SponsorUsersListPage match={{ url: "/sponsor-users" }} />, {
initialState: buildState(stateOverrides)
});

// ── Tests ──────────────────────────────────────────────────────────────────────

describe("SponsorUsersListPage", () => {
beforeEach(() => {
jest.clearAllMocks();
});

describe("data fetching on mount", () => {
it("fetches sponsor users and access requests on mount", () => {
renderPage();

expect(getSponsorUsers).toHaveBeenCalled();
expect(getSponsorUserRequests).toHaveBeenCalled();
});
});

describe("polling for import tasks", () => {
beforeEach(() => {
jest.useFakeTimers();
});

afterEach(() => {
jest.useRealTimers();
});

it("polls trackImportSponsorUsers while an import task is pending", () => {
renderPage({ importTasks: [123] });

expect(trackImportSponsorUsers).not.toHaveBeenCalled();

act(() => {
jest.advanceTimersByTime(TEN_SECONDS_IN_MILLISECONDS);
});

expect(trackImportSponsorUsers).toHaveBeenCalledTimes(1);
});

it("does not poll when there are no import tasks", () => {
renderPage({ importTasks: [] });

act(() => {
jest.advanceTimersByTime(TEN_SECONDS_IN_MILLISECONDS * 3);
});

expect(trackImportSponsorUsers).not.toHaveBeenCalled();
});

it("stops polling once importTasks is cleared", () => {
// Use the inner component directly so we can update props via rerender
// without Provider/connect wrapping interfering with effect cleanup timing.
const UnconnectedPage = SponsorUsersListPage.WrappedComponent;
const trackMock = jest.fn(() => ({ type: "MOCK_ACTION" }));
const sharedProps = {
summitId: 1,
match: { url: "/sponsor-users" },
users: DEFAULT_USERS,
requests: DEFAULT_REQUESTS,
term: "",
getSponsorUserRequests: jest.fn(),
getSponsorUsers: jest.fn(),
deleteSponsorUserRequest: jest.fn(),
deleteSponsorUser: jest.fn(),
trackImportSponsorUsers: trackMock
};

const { rerender } = render(
<UnconnectedPage {...sharedProps} importTasks={[123]} />
);

act(() => {
jest.advanceTimersByTime(TEN_SECONDS_IN_MILLISECONDS);
});
expect(trackMock).toHaveBeenCalledTimes(1);

rerender(<UnconnectedPage {...sharedProps} importTasks={[]} />);

act(() => {
jest.advanceTimersByTime(TEN_SECONDS_IN_MILLISECONDS * 3);
});

// Still only 1 call — interval was cleared when tasks were removed
expect(trackMock).toHaveBeenCalledTimes(1);
});

it("clears the polling interval when the component unmounts", () => {
const { unmount } = renderPage({ importTasks: [123] });

unmount();

act(() => {
jest.advanceTimersByTime(TEN_SECONDS_IN_MILLISECONDS * 3);
});

expect(trackImportSponsorUsers).not.toHaveBeenCalled();
});
});
});
Loading
Loading