Skip to content
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
74 changes: 38 additions & 36 deletions src/actions/sponsor-users-actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -504,46 +504,48 @@ 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(stopLoading());
Comment thread
smarcet marked this conversation as resolved.
Outdated
dispatch(
snackbarSuccessHandler({
title: T.translate("general.success"),
html: T.translate("sponsor_users.new_user.success")
})
);
})
.catch(console.log) // need to catch promise reject
Comment thread
tomrndom marked this conversation as resolved.
Outdated
.finally(() => {
dispatch(stopLoading());
});
Comment thread
smarcet marked this conversation as resolved.
};

export const fetchSponsorUsersBySummit = async (
currentSummitId,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import React, { useEffect, 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 {
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";

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 popup (and the legacy ones touched in this PR) import several components from local src/components/mui/* that already exist in openstack-uicore-foundation (currently installed at 5.0.36):

  • SummitsDropdown (this file, and import-users-popup.js) -> openstack-uicore-foundation/lib/components/mui/summits-dropdown
  • MuiFormikAsyncAutocomplete (this line) -> openstack-uicore-foundation/lib/components/mui/formik-inputs/async-select
  • CheckBoxList (used in the legacy import-users-popup.js - this file already imports it correctly from uicore) -> openstack-uicore-foundation/lib/components/mui/checkbox-list
  • MuiFormikTextField (used in the legacy new-user-popup.js - sponsor-global-new-user-popup.js already imports it correctly from uicore) -> openstack-uicore-foundation/lib/components/mui/formik-inputs/textfield
  • CustomAlert (used in both new popups) -> openstack-uicore-foundation/lib/components/mui/custom-alert

This is the documented pattern: https://github.com/fntechgit/ftn-docsnsklz/blob/main/patterns/show-admin/summit-admin-reuse-before-build.md - check uicore for an existing equivalent before adding/using a local one.

Two of these aren't just import-path noise: CustomAlert's local copy renders {message} as plain text where uicore's uses dangerouslySetInnerHTML (so it won't render HTML in a translation the way uicore's does), and MuiFormikAsyncAutocomplete's local copy carries the same "doesn't refetch on queryParams change" bug flagged in the thread above - switching to the uicore import would fix it for every consumer instead of just this popup. SummitsDropdown's divergence looks intentional (it reads allSummits from Redux instead of fetching on its own), so that one's probably fine to keep local.

None of these 5 local files were introduced by this PR - just flagging since this PR already imports CheckBoxList and MuiSponsorInput/MuiFormikTextField from uicore in the new popups, so switching the remaining two is a small diff away from being fully consistent.

import { querySponsors } from "../../../../actions/sponsor-actions";

const SponsorGlobalImportUsersPopup = ({
currentSummit,
onClose,
importSponsorUsers
}) => {
const [selectedSummit, setSelectedSummit] = useState(null);
const [userOptions, setUserOptions] = useState(null);
const [selectedUsers, setSelectedUsers] = useState([]);

const formik = useFormik({
initialValues: { sponsor: { id: "", name: "" } },
validationSchema: yup.object({
sponsor: yup.object({
id: yup.string().required(T.translate("validation.required"))
})
}),
Comment on lines +44 to +49
onSubmit: () => {},
validateOnBlur: false,
enableReinitialize: true
});

const sponsorId = formik.values.sponsor?.value;
const companyId = formik.values.sponsor?.companyId;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Comment thread
smarcet marked this conversation as resolved.
useEffect(() => {
if (selectedSummit && sponsorId && companyId) {
fetchSponsorUsersBySummit(
currentSummit.id,
selectedSummit,
companyId,
1
).then((userData) => {
Comment thread
tomrndom marked this conversation as resolved.
setUserOptions(userData);
setSelectedUsers([]);
});
}
}, [selectedSummit, sponsorId, companyId]);
Comment thread
tomrndom marked this conversation as resolved.
Comment thread
smarcet marked this conversation as resolved.
Comment thread
smarcet marked this conversation as resolved.

const handleLoadMoreUsers = () => {
Comment thread
tomrndom marked this conversation as resolved.
if (userOptions.current_page < userOptions.last_page) {
fetchSponsorUsersBySummit(
currentSummit.id,
selectedSummit,
companyId,
userOptions.current_page + 1
).then((userData) => {
setUserOptions((value) => ({
...userData,
data: [...value.data, ...userData.data]
}));
});
}
};

const handleClose = () => {
onClose();
Comment thread
tomrndom marked this conversation as resolved.
};

const handleImport = async () => {
importSponsorUsers(
sponsorId,
companyId,
selectedSummit,
selectedUsers
).then(() => {
onClose();
});
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const handleSelectOnChange = (items, all = false) => {
if (all) {
setSelectedUsers("all");
} else {
setSelectedUsers(items);
}
};

return (
<Dialog open onClose={handleClose} maxWidth="sm" fullWidth>
<DialogTitle
sx={{ display: "flex", justifyContent: "space-between", p: 2 }}
component="div"
>
<Typography variant="h5">
{T.translate("sponsor_users.import_users.title")}
</Typography>
<IconButton size="large" sx={{ p: 0 }} onClick={handleClose}>
<CloseIcon fontSize="large" />
</IconButton>
</DialogTitle>
<Divider />
<FormikProvider value={formik}>
<DialogContent sx={{ p: 2 }}>
<SummitsDropdown
onChange={(val) => {
setSelectedSummit(val);
setUserOptions(null);
setSelectedUsers([]);
}}
Comment on lines +180 to +185

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 — SummitsDropdown's onChange only resets userOptions/selectedUsers, not the Formik sponsor field, and MuiFormikAsyncAutocomplete only fetches options once on mount (no re-fetch on queryParams change). So after picking summit A → sponsor X, switching to summit B keeps sponsor X selected (now stale/wrong-summit) with stale options in the dropdown, until the user manually reselects. Suggest resetting formik.setFieldValue("sponsor", { id: "", name: "" }) (or resetForm) in the SummitsDropdown onChange handler alongside the existing setUserOptions(null)/setSelectedUsers([]).

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 Following up on this thread - the suggested fix was applied (formik.setFieldValue("sponsor", { id: "", name: "" }) now runs in the SummitsDropdown onChange, alongside setUserOptions(null)/setSelectedUsers([])), but that only clears the selected value. The other half of the original report - the sponsor dropdown's options list staying stale after switching summits - is not fixed by it and is still reproducible.

Root cause: MuiFormikAsyncAutocomplete only fetches options on mount and on searchTerm changes; its effects don't depend on queryParams. Since this popup doesn't key the component on selectedSummit, the same instance stays mounted across a summit switch and keeps showing the previous summit's sponsor list until the user types a search character.

Verified this empirically (not just by reading the code): rendered MuiFormikAsyncAutocomplete directly with a mocked queryFunction, then re-rendered with a different queryParams value with no search term in between:

const queryFunction = jest.fn((input, param, callback) => {
  callback([{ id: 1, name: `sponsor-for-${param}` }]);
  return Promise.resolve();
});

// 1st render: queryParams=["summitA"] -> queryFunction called once
// rerender with queryParams=["summitB"], no search term typed
// queryFunction call count stayed at 1 -> confirmed no refetch happened

Suggested fix: key the Autocomplete on selectedSummit to force a remount on summit change (simplest, scoped to this popup), or add queryParams to the preload effect's dependency array in the shared mui-formik-async-select.js component (fixes it for every consumer, but is a shared-component change worth its own review).

Comment on lines +180 to +185
excludeSummitIds={[currentSummit.id]}
/>
{selectedSummit && (
<Box sx={{ mb: 2, mt: 2 }}>
<MuiFormikAsyncAutocomplete
name="sponsor"
queryFunction={querySponsors}
queryParams={[selectedSummit]}
placeholder={T.translate(
"sponsor_users.process_request.select_sponsor"
)}
formatOption={(item) => ({
value: item.id,
label: item.name,
companyId: item.companyId
})}
formatSelectedValue={(s) => ({
id: parseInt(s.value),
name: s.label,
companyId: s.companyId
})}
/>
</Box>
)}
{selectedSummit && sponsorId && userOptions && (
<>
<Typography
variant="body1"
gutterBottom
sx={{ color: "text.secondary", mt: 2 }}
>
{T.translate("sponsor_users.import_users.select_users")}
</Typography>
<Card variant="outlined">
<CheckBoxList
items={userOptions.data.map((it) => ({
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"
/>
</Card>
</>
)}
</DialogContent>
</FormikProvider>
<Divider sx={{ margin: "10px 0px 20px 0px" }} />
<DialogActions>
<Button
fullWidth
variant="contained"
onClick={handleImport}
disabled={selectedUsers.length === 0}
>
{T.translate("sponsor_users.import_users.import_users")}
</Button>
</DialogActions>
</Dialog>
);
};

const mapStateToProps = ({ currentSummitState }) => ({
currentSummit: currentSummitState.currentSummit
});

export default connect(mapStateToProps, {
importSponsorUsers
})(SponsorGlobalImportUsersPopup);
Loading
Loading