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 ( + + + + {T.translate("sponsor_users.import_users.title")} + + + + + + + + + { + setSelectedSummit(val); + formik.setFieldValue("sponsor", { id: "", name: "" }); + setUserOptions(null); + setSelectedUsers([]); + }} + excludeSummitIds={[summitId]} + /> + {selectedSummit && ( + + ({ + value: item.id, + label: item.name, + companyId: item.companyId + })} + formatSelectedValue={(s) => ({ + id: parseInt(s.value), + name: s.label, + companyId: s.companyId + })} + /> + + )} + {selectedSummit && sponsorId && userOptions && ( + <> + + {T.translate("sponsor_users.import_users.select_users")} + + + ({ + id: it.id, + name: + it.first_name && it.last_name + ? `${it.first_name} ${it.last_name}` + : it.email + }))} + onChange={handleSelectOnChange} + label={T.translate( + "sponsor_users.import_users.select_all_users" + )} + loadMoreData={handleLoadMoreUsers} + boxHeight="200px" + /> + + + )} + + + + + + + + ); +}; + +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 ( + + + + {T.translate("sponsor_users.new_user.add_user")} + + + + + + + + + + + + + + + + + + + + + ); +}; + +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);