diff --git a/src/actions/sponsor-actions.js b/src/actions/sponsor-actions.js
index 94c116f0c..1f950a671 100644
--- a/src/actions/sponsor-actions.js
+++ b/src/actions/sponsor-actions.js
@@ -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";
@@ -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);
})
diff --git a/src/actions/sponsor-users-actions.js b/src/actions/sponsor-users-actions.js
index ed7e87266..ab8455d55 100644
--- a/src/actions/sponsor-users-actions.js
+++ b/src/actions/sponsor-users-actions.js
@@ -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";
@@ -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";
@@ -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)
+ .then(() => {
+ dispatch(
+ snackbarSuccessHandler({
+ title: T.translate("general.success"),
+ html: T.translate("sponsor_users.new_user.success")
+ })
+ );
+ })
+ .finally(() => {
+ dispatch(stopLoading());
+ });
+ };
export const fetchSponsorUsersBySummit = async (
currentSummitId,
@@ -652,7 +650,6 @@ export const importSponsorUsers =
})
);
})
- .catch(console.log) // need to catch promise reject
.finally(() => {
dispatch(stopLoading());
});
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 6d56f6adf..664b43104 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -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": {
diff --git a/src/pages/sponsors/sponsor-page/tabs/sponsor-users-list-per-sponsor/components/import-users-popup.js b/src/pages/sponsors/sponsor-page/tabs/sponsor-users-list-per-sponsor/components/import-users-popup.js
index 3a6f8259d..be320d715 100644
--- a/src/pages/sponsors/sponsor-page/tabs/sponsor-users-list-per-sponsor/components/import-users-popup.js
+++ b/src/pages/sponsors/sponsor-page/tabs/sponsor-users-list-per-sponsor/components/import-users-popup.js
@@ -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) => {
diff --git a/src/pages/sponsors/sponsor-page/tabs/sponsor-users-list-per-sponsor/components/new-user-popup.js b/src/pages/sponsors/sponsor-page/tabs/sponsor-users-list-per-sponsor/components/new-user-popup.js
index 114fd5526..bbab778e4 100644
--- a/src/pages/sponsors/sponsor-page/tabs/sponsor-users-list-per-sponsor/components/new-user-popup.js
+++ b/src/pages/sponsors/sponsor-page/tabs/sponsor-users-list-per-sponsor/components/new-user-popup.js
@@ -35,10 +35,12 @@ const NewUserPopup = ({
};
const handleOnSave = (values) => {
- sendSponsorUserInvite(values.email).finally(() => {
- getSponsorUsers(sponsorId);
- handleClose();
- });
+ sendSponsorUserInvite(values.email)
+ .catch(() => {})
+ .finally(() => {
+ getSponsorUsers(sponsorId);
+ handleClose();
+ });
};
const formik = useFormik({
diff --git a/src/pages/sponsors/sponsor-users-list-page/__tests__/sponsor-users-list-page.test.js b/src/pages/sponsors/sponsor-users-list-page/__tests__/sponsor-users-list-page.test.js
new file mode 100644
index 000000000..14634822f
--- /dev/null
+++ b/src/pages/sponsors/sponsor-users-list-page/__tests__/sponsor-users-list-page.test.js
@@ -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: () =>
+}));
+
+jest.mock(
+ "openstack-uicore-foundation/lib/components/mui/search-input",
+ () =>
+ function SearchInputMock() {
+ return ;
+ }
+);
+
+jest.mock("../components/request-table", () => () => (
+
+));
+
+jest.mock("../components/users-table", () => () => (
+
+));
+
+jest.mock("../components/sponsor-global-new-user-popup", () => () => (
+
+));
+
+jest.mock("../components/sponsor-global-import-users-popup", () => () => (
+
+));
+
+jest.mock("../components/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(, {
+ 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(
+
+ );
+
+ act(() => {
+ jest.advanceTimersByTime(TEN_SECONDS_IN_MILLISECONDS);
+ });
+ expect(trackMock).toHaveBeenCalledTimes(1);
+
+ rerender();
+
+ 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();
+ });
+ });
+});
diff --git a/src/pages/sponsors/sponsor-users-list-page/components/__tests__/sponsor-global-import-users-popup.test.js b/src/pages/sponsors/sponsor-users-list-page/components/__tests__/sponsor-global-import-users-popup.test.js
new file mode 100644
index 000000000..b1884ab60
--- /dev/null
+++ b/src/pages/sponsors/sponsor-users-list-page/components/__tests__/sponsor-global-import-users-popup.test.js
@@ -0,0 +1,328 @@
+import React from "react";
+import { act, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { renderWithRedux } from "../../../../../utils/test-utils";
+import SponsorGlobalImportUsersPopup from "../sponsor-global-import-users-popup";
+import * as sponsorUsersActions from "../../../../../actions/sponsor-users-actions";
+
+jest.mock("i18n-react/dist/i18n-react", () => ({
+ __esModule: true,
+ default: { translate: (key) => key }
+}));
+
+jest.mock("../../../../../actions/sponsor-users-actions", () => {
+ const original = jest.requireActual(
+ "../../../../../actions/sponsor-users-actions"
+ );
+ return {
+ __esModule: true,
+ ...original,
+ fetchSponsorUsersBySummit: jest.fn(),
+ fetchSponsorByCompany: jest.fn(),
+ importSponsorUsers: jest.fn(() => () => Promise.resolve())
+ };
+});
+
+jest.mock("../../../../../actions/sponsor-actions", () => {
+ const original = jest.requireActual("../../../../../actions/sponsor-actions");
+ return {
+ __esModule: true,
+ ...original,
+ querySponsors: jest.fn()
+ };
+});
+
+jest.mock("../../../../../components/mui/summits-dropdown", () => ({
+ __esModule: true,
+ default: ({ onChange, excludeSummitIds }) => (
+
+ )
+}));
+
+jest.mock(
+ "../../../../../components/mui/formik-inputs/mui-formik-async-select",
+ () => {
+ const React = require("react");
+ const { useFormikContext } = require("formik");
+ // Two entries so successive clicks return different values — required for the
+ // "sponsor changes" test, which relies on the useEffect seeing a new sponsorId/companyId.
+ const sponsors = [
+ { value: 1, label: "Test Sponsor", companyId: 42 },
+ { value: 2, label: "Other Sponsor", companyId: 99 }
+ ];
+ return {
+ __esModule: true,
+ default: ({ name }) => {
+ const { setFieldValue } = useFormikContext();
+ const [clickCount, setClickCount] = React.useState(0);
+ return (
+
+ );
+ }
+ };
+ }
+);
+
+jest.mock(
+ "openstack-uicore-foundation/lib/components/mui/checkbox-list",
+ () => ({
+ __esModule: true,
+ default: ({ items, onChange }) => (
+
+ {items.map((item) => (
+
+ ))}
+
+
+ )
+ })
+);
+
+const mockCurrentSummit = { id: 1, name: "Current Summit" };
+
+const mockUserData = {
+ data: [
+ {
+ id: 10,
+ first_name: "Alice",
+ last_name: "Smith",
+ email: "alice@example.com"
+ },
+ {
+ id: 11,
+ first_name: "Bob",
+ last_name: "Jones",
+ email: "bob@example.com"
+ }
+ ],
+ current_page: 1,
+ last_page: 1
+};
+
+const baseState = {
+ currentSummitState: { currentSummit: mockCurrentSummit }
+};
+
+const renderPopup = (props = {}) =>
+ renderWithRedux(
+ ,
+ { initialState: baseState }
+ );
+
+const selectSummitAndSponsor = async () => {
+ await act(async () => {
+ await userEvent.click(screen.getByTestId("summit-select"));
+ });
+ await act(async () => {
+ await userEvent.click(screen.getByTestId("sponsor-async-select"));
+ });
+ await waitFor(() => {
+ expect(screen.getByTestId("checkbox-list")).toBeInTheDocument();
+ });
+};
+
+describe("SponsorGlobalImportUsersPopup", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ sponsorUsersActions.fetchSponsorUsersBySummit.mockResolvedValue(
+ mockUserData
+ );
+ sponsorUsersActions.fetchSponsorByCompany.mockResolvedValue({
+ id: 999,
+ name: "Test Sponsor"
+ });
+ sponsorUsersActions.importSponsorUsers.mockReturnValue(() =>
+ Promise.resolve()
+ );
+ });
+
+ it("renders the title, summit dropdown and excludes the current summit", () => {
+ renderPopup();
+
+ expect(
+ screen.getByText("sponsor_users.import_users.title")
+ ).toBeInTheDocument();
+ expect(screen.getByTestId("summit-select")).toBeInTheDocument();
+ expect(
+ screen.getByText(`Select Summit (excluding: ${mockCurrentSummit.id})`)
+ ).toBeInTheDocument();
+ });
+
+ it("shows the sponsor dropdown after a summit is selected", async () => {
+ renderPopup();
+
+ await act(async () => {
+ await userEvent.click(screen.getByTestId("summit-select"));
+ });
+
+ expect(screen.getByTestId("sponsor-async-select")).toBeInTheDocument();
+ });
+
+ it("calls fetchSponsorUsersBySummit with the correct params when summit and sponsor are selected", async () => {
+ renderPopup();
+
+ await act(async () => {
+ await userEvent.click(screen.getByTestId("summit-select"));
+ });
+ await act(async () => {
+ await userEvent.click(screen.getByTestId("sponsor-async-select"));
+ });
+
+ await waitFor(() => {
+ expect(
+ sponsorUsersActions.fetchSponsorUsersBySummit
+ ).toHaveBeenCalledWith(
+ mockCurrentSummit.id, // currentSummitId
+ 2, // selectedSummitId
+ 42, // companyId
+ 1 // page
+ );
+ });
+ });
+
+ it("shows the user list and keeps import button disabled until a user is selected", async () => {
+ renderPopup();
+
+ await selectSummitAndSponsor();
+
+ expect(screen.getByTestId("user-item-10")).toBeInTheDocument();
+ expect(screen.getByTestId("user-item-11")).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", {
+ name: "sponsor_users.import_users.import_users"
+ })
+ ).toBeDisabled();
+
+ await act(async () => {
+ await userEvent.click(screen.getByTestId("user-item-10"));
+ });
+
+ expect(
+ screen.getByRole("button", {
+ name: "sponsor_users.import_users.import_users"
+ })
+ ).not.toBeDisabled();
+ });
+
+ it("calls importSponsorUsers with correct params and closes on success", async () => {
+ const onClose = jest.fn();
+ renderPopup({ onClose });
+
+ await selectSummitAndSponsor();
+
+ await act(async () => {
+ await userEvent.click(screen.getByTestId("user-item-10"));
+ });
+
+ await act(async () => {
+ await userEvent.click(
+ screen.getByRole("button", {
+ name: "sponsor_users.import_users.import_users"
+ })
+ );
+ });
+
+ await waitFor(() => {
+ expect(sponsorUsersActions.fetchSponsorByCompany).toHaveBeenCalledWith(
+ 42, // companyId
+ mockCurrentSummit.id // current (target) summitId
+ );
+ expect(sponsorUsersActions.importSponsorUsers).toHaveBeenCalledWith(
+ 999, // sponsorId resolved via fetchSponsorByCompany for the target summit
+ 42, // companyId
+ 2, // selectedSummitId
+ [10] // selectedUsers
+ );
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ it("calls importSponsorUsers with 'all' when select all is used", async () => {
+ renderPopup();
+
+ await selectSummitAndSponsor();
+
+ await act(async () => {
+ await userEvent.click(screen.getByTestId("select-all-users"));
+ });
+
+ await act(async () => {
+ await userEvent.click(
+ screen.getByRole("button", {
+ name: "sponsor_users.import_users.import_users"
+ })
+ );
+ });
+
+ await waitFor(() => {
+ expect(sponsorUsersActions.importSponsorUsers).toHaveBeenCalledWith(
+ 1,
+ 42,
+ 2,
+ "all"
+ );
+ });
+ });
+
+ it("clears user list and selection immediately when sponsor changes", async () => {
+ renderPopup();
+
+ // first sponsor selection — loads users, picks one
+ await selectSummitAndSponsor();
+ await act(async () => {
+ await userEvent.click(screen.getByTestId("user-item-10"));
+ });
+ expect(
+ screen.getByRole("button", {
+ name: "sponsor_users.import_users.import_users"
+ })
+ ).not.toBeDisabled();
+
+ // change sponsor — user list must disappear before the new fetch resolves
+ sponsorUsersActions.fetchSponsorUsersBySummit.mockReturnValueOnce(
+ new Promise(() => {})
+ ); // never resolves
+ await act(async () => {
+ await userEvent.click(screen.getByTestId("sponsor-async-select"));
+ });
+
+ expect(screen.queryByTestId("user-item-10")).not.toBeInTheDocument();
+ expect(
+ screen.getByRole("button", {
+ name: "sponsor_users.import_users.import_users"
+ })
+ ).toBeDisabled();
+ });
+});
diff --git a/src/pages/sponsors/sponsor-users-list-page/components/__tests__/sponsor-global-new-user-popup.test.js b/src/pages/sponsors/sponsor-users-list-page/components/__tests__/sponsor-global-new-user-popup.test.js
new file mode 100644
index 000000000..7ce2e5650
--- /dev/null
+++ b/src/pages/sponsors/sponsor-users-list-page/components/__tests__/sponsor-global-new-user-popup.test.js
@@ -0,0 +1,212 @@
+import React from "react";
+import { act, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { renderWithRedux } from "../../../../../utils/test-utils";
+import SponsorGlobalNewUserPopup from "../sponsor-global-new-user-popup";
+import * as sponsorUsersActions from "../../../../../actions/sponsor-users-actions";
+
+jest.mock("i18n-react/dist/i18n-react", () => ({
+ __esModule: true,
+ default: { translate: (key) => key }
+}));
+
+jest.mock("../../../../../actions/sponsor-users-actions", () => {
+ const original = jest.requireActual(
+ "../../../../../actions/sponsor-users-actions"
+ );
+ return {
+ __esModule: true,
+ ...original,
+ sendSponsorUserInvite: jest.fn(() => () => Promise.resolve()),
+ getSponsorUsers: jest.fn(() => ({ type: "MOCK_GET_SPONSOR_USERS" }))
+ };
+});
+
+jest.mock(
+ "openstack-uicore-foundation/lib/components/mui/formik-inputs/sponsor-input",
+ () => {
+ const { useFormikContext } = require("formik");
+ return {
+ __esModule: true,
+ default: ({ name }) => {
+ const { setFieldValue } = useFormikContext();
+ return (
+
+ );
+ }
+ };
+ }
+);
+
+jest.mock(
+ "openstack-uicore-foundation/lib/components/mui/formik-inputs/textfield",
+ () => {
+ const { useFormikContext } = require("formik");
+ return {
+ __esModule: true,
+ default: ({ name, label }) => {
+ const { values, setFieldValue } = useFormikContext();
+ return (
+ setFieldValue(name, e.target.value)}
+ />
+ );
+ }
+ };
+ }
+);
+
+jest.mock("../../../../../components/mui/custom-alert", () => ({
+ __esModule: true,
+ default: () => null
+}));
+
+const defaultProps = {
+ onClose: jest.fn(),
+ summitId: 1
+};
+
+const renderPopup = (props = {}) =>
+ renderWithRedux(, {
+ initialState: {}
+ });
+
+describe("SponsorGlobalNewUserPopup", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ sponsorUsersActions.sendSponsorUserInvite.mockReturnValue(() =>
+ Promise.resolve()
+ );
+ sponsorUsersActions.getSponsorUsers.mockReturnValue({
+ type: "MOCK_GET_SPONSOR_USERS"
+ });
+ });
+
+ it("renders title, email field and invite button", () => {
+ renderPopup();
+
+ expect(
+ screen.getByText("sponsor_users.new_user.add_user")
+ ).toBeInTheDocument();
+ expect(screen.getByTestId("field-email")).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", {
+ name: "sponsor_users.new_user.invite"
+ })
+ ).toBeInTheDocument();
+ });
+
+ it("submits invite with correct data then refreshes list and closes", async () => {
+ const onClose = jest.fn();
+ renderPopup({ onClose });
+
+ await act(async () => {
+ await userEvent.click(screen.getByTestId("sponsor-input"));
+ });
+
+ await act(async () => {
+ await userEvent.type(
+ screen.getByTestId("field-email"),
+ "user@example.com"
+ );
+ });
+
+ await act(async () => {
+ await userEvent.click(
+ screen.getByRole("button", { name: "sponsor_users.new_user.invite" })
+ );
+ });
+
+ await waitFor(() => {
+ expect(sponsorUsersActions.sendSponsorUserInvite).toHaveBeenCalledWith(
+ "user@example.com",
+ "42"
+ );
+ expect(sponsorUsersActions.getSponsorUsers).toHaveBeenCalled();
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ it("keeps dialog open and does not refresh users when invite fails", async () => {
+ const onClose = jest.fn();
+ sponsorUsersActions.sendSponsorUserInvite.mockReturnValue(() =>
+ Promise.reject(new Error("already exists"))
+ );
+ renderPopup({ onClose });
+
+ await act(async () => {
+ await userEvent.click(screen.getByTestId("sponsor-input"));
+ });
+ await act(async () => {
+ await userEvent.type(
+ screen.getByTestId("field-email"),
+ "user@example.com"
+ );
+ });
+ await act(async () => {
+ await userEvent.click(
+ screen.getByRole("button", { name: "sponsor_users.new_user.invite" })
+ );
+ });
+
+ await waitFor(() => {
+ expect(sponsorUsersActions.sendSponsorUserInvite).toHaveBeenCalled();
+ expect(onClose).not.toHaveBeenCalled();
+ expect(sponsorUsersActions.getSponsorUsers).not.toHaveBeenCalled();
+ });
+ });
+
+ it("does not submit when email is invalid", async () => {
+ renderPopup();
+
+ await act(async () => {
+ await userEvent.click(screen.getByTestId("sponsor-input"));
+ });
+
+ await act(async () => {
+ await userEvent.type(screen.getByTestId("field-email"), "not-an-email");
+ });
+
+ await act(async () => {
+ await userEvent.click(
+ screen.getByRole("button", { name: "sponsor_users.new_user.invite" })
+ );
+ });
+
+ await waitFor(() => {
+ expect(sponsorUsersActions.sendSponsorUserInvite).not.toHaveBeenCalled();
+ });
+ });
+
+ it("does not submit when sponsor is not selected", async () => {
+ renderPopup();
+
+ await act(async () => {
+ await userEvent.type(
+ screen.getByTestId("field-email"),
+ "user@example.com"
+ );
+ });
+
+ await act(async () => {
+ await userEvent.click(
+ screen.getByRole("button", { name: "sponsor_users.new_user.invite" })
+ );
+ });
+
+ await waitFor(() => {
+ expect(sponsorUsersActions.sendSponsorUserInvite).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/src/pages/sponsors/sponsor-users-list-page/components/sponsor-global-import-users-popup.js b/src/pages/sponsors/sponsor-users-list-page/components/sponsor-global-import-users-popup.js
new file mode 100644
index 000000000..7cc0782ce
--- /dev/null
+++ b/src/pages/sponsors/sponsor-users-list-page/components/sponsor-global-import-users-popup.js
@@ -0,0 +1,257 @@
+import React, { useEffect, useRef, useState } from "react";
+import T from "i18n-react/dist/i18n-react";
+import { connect } from "react-redux";
+import {
+ Box,
+ Button,
+ Card,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogTitle,
+ Divider,
+ IconButton,
+ Typography
+} from "@mui/material";
+import CloseIcon from "@mui/icons-material/Close";
+import { FormikProvider, useFormik } from "formik";
+import * as yup from "yup";
+import CheckBoxList from "openstack-uicore-foundation/lib/components/mui/checkbox-list";
+import { snackbarErrorMsg } from "openstack-uicore-foundation/lib/utils/actions";
+import {
+ fetchSponsorByCompany,
+ fetchSponsorUsersBySummit,
+ importSponsorUsers
+} from "../../../../actions/sponsor-users-actions";
+import SummitsDropdown from "../../../../components/mui/summits-dropdown";
+import MuiFormikAsyncAutocomplete from "../../../../components/mui/formik-inputs/mui-formik-async-select";
+import { querySponsors } from "../../../../actions/sponsor-actions";
+import { DEFAULT_CURRENT_PAGE } from "../../../../utils/constants";
+
+const SponsorGlobalImportUsersPopup = ({
+ summitId,
+ onClose,
+ importSponsorUsers
+}) => {
+ const [selectedSummit, setSelectedSummit] = useState(null);
+ const [userOptions, setUserOptions] = useState(null);
+ const [selectedUsers, setSelectedUsers] = useState([]);
+ const [isSaving, setIsSaving] = useState(false);
+ const [isLoadingMore, setIsLoadingMore] = useState(false);
+ const loadMoreRequestIdRef = useRef(0);
+
+ const formik = useFormik({
+ initialValues: { sponsor: { id: "", name: "" } },
+ validationSchema: yup.object({
+ sponsor: yup.object({
+ id: yup.string().required(T.translate("validation.required"))
+ })
+ }),
+ onSubmit: () => {},
+ validateOnBlur: false,
+ enableReinitialize: true
+ });
+
+ const sponsorId = formik.values.sponsor?.value;
+ const companyId = formik.values.sponsor?.companyId;
+
+ useEffect(() => {
+ if (selectedSummit && sponsorId && companyId) {
+ loadMoreRequestIdRef.current += 1; // invalidate in-flight load-more requests
+ let cancelled = false;
+ setUserOptions(null);
+ setSelectedUsers([]);
+ fetchSponsorUsersBySummit(
+ summitId,
+ selectedSummit,
+ companyId,
+ DEFAULT_CURRENT_PAGE
+ ).then((userData) => {
+ if (!cancelled) {
+ setUserOptions(userData);
+ setSelectedUsers([]);
+ }
+ });
+ return () => {
+ cancelled = true;
+ };
+ }
+ setUserOptions(null);
+ setSelectedUsers([]);
+ }, [selectedSummit, sponsorId, companyId]);
+
+ const handleLoadMoreUsers = () => {
+ if (isLoadingMore || !userOptions) return;
+ if (userOptions.current_page < userOptions.last_page) {
+ const requestId = ++loadMoreRequestIdRef.current;
+ setIsLoadingMore(true);
+ fetchSponsorUsersBySummit(
+ summitId,
+ selectedSummit,
+ companyId,
+ userOptions.current_page + 1
+ )
+ .then((userData) => {
+ if (requestId !== loadMoreRequestIdRef.current || !userData?.data)
+ return;
+ setUserOptions((prev) => {
+ if (!prev) return userData;
+ return {
+ ...userData,
+ data: [...(prev.data || []), ...userData.data]
+ };
+ });
+ })
+ .catch(() =>
+ snackbarErrorMsg({
+ title: T.translate("general.error"),
+ html: T.translate("sponsor_users.import_users.fetch_users_fail")
+ })
+ )
+ .finally(() => setIsLoadingMore(false));
+ }
+ };
+
+ const handleClose = () => {
+ if (isSaving) return;
+ onClose();
+ };
+
+ const handleImport = async () => {
+ if (isSaving) return;
+ setIsSaving(true);
+
+ const runImport = (targetSponsorId) =>
+ importSponsorUsers(
+ targetSponsorId,
+ companyId,
+ selectedSummit,
+ selectedUsers
+ ).then(() => onClose());
+
+ // "apply to all" is resolved by the backend via companyId + target summit,
+ // so only the specific-users path needs the target summit's own sponsor id.
+ (selectedUsers === "all"
+ ? runImport(sponsorId)
+ : fetchSponsorByCompany(companyId, summitId).then((targetSponsor) =>
+ runImport(targetSponsor.id)
+ )
+ )
+ .catch(() => {})
+ .finally(() => setIsSaving(false));
+ };
+
+ const handleSelectOnChange = (items, all = false) => {
+ if (all) {
+ setSelectedUsers("all");
+ } else {
+ setSelectedUsers(items);
+ }
+ };
+
+ return (
+
+ );
+};
+
+export default connect(null, {
+ importSponsorUsers
+})(SponsorGlobalImportUsersPopup);
diff --git a/src/pages/sponsors/sponsor-users-list-page/components/sponsor-global-new-user-popup.js b/src/pages/sponsors/sponsor-users-list-page/components/sponsor-global-new-user-popup.js
new file mode 100644
index 000000000..069ba71df
--- /dev/null
+++ b/src/pages/sponsors/sponsor-users-list-page/components/sponsor-global-new-user-popup.js
@@ -0,0 +1,145 @@
+import React, { useState } from "react";
+import { connect } from "react-redux";
+import PropTypes from "prop-types";
+import T from "i18n-react/dist/i18n-react";
+import {
+ Box,
+ Button,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogTitle,
+ Divider,
+ IconButton,
+ Typography
+} from "@mui/material";
+import CloseIcon from "@mui/icons-material/Close";
+import { FormikProvider, useFormik } from "formik";
+import * as yup from "yup";
+import MuiSponsorInput from "openstack-uicore-foundation/lib/components/mui/formik-inputs/sponsor-input";
+import MuiFormikTextField from "openstack-uicore-foundation/lib/components/mui/formik-inputs/textfield";
+import CustomAlert from "../../../../components/mui/custom-alert";
+import {
+ sendSponsorUserInvite,
+ getSponsorUsers
+} from "../../../../actions/sponsor-users-actions";
+
+const SponsorGlobalNewUserPopup = ({
+ onClose,
+ summitId,
+ sendSponsorUserInvite,
+ getSponsorUsers
+}) => {
+ const [isSaving, setIsSaving] = useState(false);
+
+ const handleClose = () => {
+ if (isSaving) return;
+ onClose();
+ };
+
+ const handleOnSave = (values) => {
+ if (isSaving) return;
+ setIsSaving(true);
+ sendSponsorUserInvite(values.email, values.sponsor.id)
+ .then(() => {
+ getSponsorUsers();
+ handleClose();
+ })
+ .catch(() => {})
+ .finally(() => setIsSaving(false));
+ };
+
+ const formik = useFormik({
+ initialValues: { sponsor: { id: "", name: "" }, email: "" },
+ validationSchema: yup.object({
+ sponsor: yup.object({
+ id: yup.string().required(T.translate("validation.required"))
+ }),
+ email: yup
+ .string(T.translate("validation.string"))
+ .email(T.translate("validation.email"))
+ .required(T.translate("validation.required"))
+ }),
+ onSubmit: handleOnSave,
+ validateOnBlur: false,
+ enableReinitialize: true
+ });
+
+ return (
+
+ );
+};
+
+SponsorGlobalNewUserPopup.propTypes = {
+ onClose: PropTypes.func.isRequired,
+ sendSponsorUserInvite: PropTypes.func.isRequired,
+ getSponsorUsers: PropTypes.func.isRequired,
+ summitId: PropTypes.number.isRequired
+};
+
+export default connect(() => {}, {
+ sendSponsorUserInvite,
+ getSponsorUsers
+})(SponsorGlobalNewUserPopup);
diff --git a/src/pages/sponsors/sponsor-users-list-page/index.js b/src/pages/sponsors/sponsor-users-list-page/index.js
index 946917fe5..64b6cde2c 100644
--- a/src/pages/sponsors/sponsor-users-list-page/index.js
+++ b/src/pages/sponsors/sponsor-users-list-page/index.js
@@ -11,7 +11,7 @@
* limitations under the License.
* */
-import React, { useEffect, useState } from "react";
+import React, { useEffect, useRef, useState } from "react";
import { connect } from "react-redux";
import T from "i18n-react/dist/i18n-react";
import { Breadcrumb } from "react-breadcrumbs";
@@ -23,30 +23,55 @@ import {
deleteSponsorUser,
deleteSponsorUserRequest,
getSponsorUserRequests,
- getSponsorUsers
+ getSponsorUsers,
+ trackImportSponsorUsers
} from "../../../actions/sponsor-users-actions";
+import { TEN_SECONDS_IN_MILLISECONDS } from "../../../utils/constants";
import RequestTable from "./components/request-table";
import UsersTable from "./components/users-table";
import EditUserPopup from "./components/edit-user-popup";
+import SponsorGlobalNewUserPopup from "./components/sponsor-global-new-user-popup";
+import SponsorGlobalImportUsersPopup from "./components/sponsor-global-import-users-popup";
const SponsorUsersListPage = ({
+ summitId,
match,
requests,
users,
term,
+ importTasks,
getSponsorUserRequests,
getSponsorUsers,
deleteSponsorUserRequest,
- deleteSponsorUser
+ deleteSponsorUser,
+ trackImportSponsorUsers
}) => {
const [openPopup, setOpenPopup] = useState(null);
const [userEdit, setUserEdit] = useState(null);
+ const importIntervalRef = useRef(null);
+ const hasImportTasks = !!importTasks?.length;
useEffect(() => {
getSponsorUserRequests();
getSponsorUsers();
}, []);
+ useEffect(() => {
+ if (hasImportTasks && !importIntervalRef.current) {
+ importIntervalRef.current = setInterval(
+ () => trackImportSponsorUsers(),
+ TEN_SECONDS_IN_MILLISECONDS
+ );
+ } else if (!hasImportTasks && importIntervalRef.current) {
+ clearInterval(importIntervalRef.current);
+ importIntervalRef.current = null;
+ }
+ return () => {
+ clearInterval(importIntervalRef.current);
+ importIntervalRef.current = null;
+ };
+ }, [hasImportTasks]);
+
const handleSearch = (searchTerm) => {
getSponsorUserRequests(null, searchTerm);
getSponsorUsers(null, searchTerm);
@@ -130,9 +155,19 @@ const SponsorUsersListPage = ({
onEdit={handleUserEdit}
/>
- {openPopup === "new" && }
+ {openPopup === "new" && (
+ setOpenPopup(null)}
+ />
+ )}
- {openPopup === "import" && }
+ {openPopup === "import" && (
+ setOpenPopup(null)}
+ />
+ )}
{userEdit && (
setUserEdit(null)} />
@@ -141,13 +176,15 @@ const SponsorUsersListPage = ({
);
};
-const mapStateToProps = ({ sponsorUsersListState }) => ({
- ...sponsorUsersListState
+const mapStateToProps = ({ sponsorUsersListState, currentSummitState }) => ({
+ ...sponsorUsersListState,
+ summitId: currentSummitState.currentSummit.id
});
export default connect(mapStateToProps, {
getSponsorUserRequests,
getSponsorUsers,
deleteSponsorUserRequest,
- deleteSponsorUser
+ deleteSponsorUser,
+ trackImportSponsorUsers
})(SponsorUsersListPage);