From c67ed4f5f0e3eac178c3aa9aa8995eeedff34eba Mon Sep 17 00:00:00 2001 From: Dmytro Melnyshyn Date: Thu, 25 Dec 2025 15:39:50 +0200 Subject: [PATCH] UIU-3499-3 --- .../EditUserRoles/EditUserRoles.js | 46 +++++++-- .../UserRolesModal/UserRolesModal.js | 2 +- src/components/Wrappers/withUserRoles.js | 33 +++++-- src/hooks/useAllRolesData/useAllRolesData.js | 13 ++- .../useUserAffiliationRoles.js | 96 ++++++++----------- src/views/UserEdit/UserEdit.js | 2 + src/views/UserEdit/UserForm.js | 4 + 7 files changed, 121 insertions(+), 75 deletions(-) diff --git a/src/components/EditSections/EditUserRoles/EditUserRoles.js b/src/components/EditSections/EditUserRoles/EditUserRoles.js index 56d981c2d..e5e581775 100644 --- a/src/components/EditSections/EditUserRoles/EditUserRoles.js +++ b/src/components/EditSections/EditUserRoles/EditUserRoles.js @@ -5,11 +5,12 @@ import PropTypes from 'prop-types'; import { isEmpty } from 'lodash'; import { FieldArray } from 'react-final-form-arrays'; import { OnChange } from 'react-final-form-listeners'; +import { useForm } from 'react-final-form'; import { IfPermission, useStripes } from '@folio/stripes/core'; -import { Accordion, Headline, Badge, Row, Col, List, Button, Icon, ConfirmationModal } from '@folio/stripes/components'; +import { Accordion, Headline, Badge, Row, Col, List, Button, Icon, ConfirmationModal, Layout } from '@folio/stripes/components'; -import { useAllRolesData, useUserAffiliations } from '../../../hooks'; +import { useAllRolesData, useUserAffiliationRoles, useUserAffiliations } from '../../../hooks'; import AffiliationsSelect from '../../AffiliationsSelect/AffiliationsSelect'; import IfConsortium from '../../IfConsortium'; import IfConsortiumPermission from '../../IfConsortiumPermission'; @@ -17,8 +18,9 @@ import UserRolesModal from './components/UserRolesModal/UserRolesModal'; import { isAffiliationsEnabled } from '../../util/util'; import { filtersConfig } from './helpers'; -function EditUserRoles({ accordionId, form:{ change }, user, setAssignedRoleIds, assignedRoleIds, setTenantId, tenantId }) { +function EditUserRoles({ accordionId, form:{ change }, user, setAssignedRoleIds, assignedRoleIds, setTenantId, tenantId, initialAssignedRoleIds, isLoadingAffiliationRoles }) { const stripes = useStripes(); + const form = useForm(); const [isOpen, setIsOpen] = useState(false); const [unassignModalOpen, setUnassignModalOpen] = useState(false); const intl = useIntl(); @@ -28,7 +30,19 @@ function EditUserRoles({ accordionId, form:{ change }, user, setAssignedRoleIds, isFetching: isAffiliationsFetching, } = useUserAffiliations({ userId: user.id }, { enabled: isAffiliationsEnabled(user) }); - const { isLoading: isAllRolesDataLoading, allRolesMapStructure, refetch } = useAllRolesData({ tenantId }); + const { + isLoading: isAllRolesDataLoading, + isFetching: isAllRolesDataFetching, + allRolesMapStructure, + refetch, + } = useAllRolesData({ tenantId }); + + const isLoadingData = ( + isAffiliationsFetching + || isLoadingAffiliationRoles + || isAllRolesDataLoading + || isAllRolesDataFetching + ); useEffect(() => { if (!affiliations.some(({ tenantId: assigned }) => tenantId === assigned)) { @@ -38,6 +52,18 @@ function EditUserRoles({ accordionId, form:{ change }, user, setAssignedRoleIds, } }, [affiliations, stripes.okapi.tenant, setTenantId, tenantId, refetch]); + // Initialize form field for the current tenant if it doesn't exist yet + useEffect(() => { + const formState = form.getState(); + const currentFieldValue = formState.values?.assignedRoleIds?.[tenantId]; + const hasInitialValue = initialAssignedRoleIds?.[tenantId]; + + // If the form field doesn't exist but we have initial values for this tenant, initialize it + if (currentFieldValue === undefined && hasInitialValue) { + change(`assignedRoleIds.${tenantId}`, initialAssignedRoleIds[tenantId]); + } + }, [tenantId, initialAssignedRoleIds, form, change]); + const changeUserRoles = (roleIds) => { change(`assignedRoleIds[${tenantId}]`, roleIds); }; @@ -107,6 +133,14 @@ function EditUserRoles({ accordionId, form:{ change }, user, setAssignedRoleIds, }; function renderUserRoles() { + if (isLoadingData) { + return ( + + + + ); + } + return ( {renderUserRoles()} - - + + diff --git a/src/components/EditSections/EditUserRoles/components/UserRolesModal/UserRolesModal.js b/src/components/EditSections/EditUserRoles/components/UserRolesModal/UserRolesModal.js index d84840c26..033ebaebf 100644 --- a/src/components/EditSections/EditUserRoles/components/UserRolesModal/UserRolesModal.js +++ b/src/components/EditSections/EditUserRoles/components/UserRolesModal/UserRolesModal.js @@ -20,7 +20,7 @@ export default function UserRolesModal({ isOpen, const [submittedSearchTerm, setSubmittedSearchTerm] = useState(''); const [assignedRoleIds, setAssignedRoleIds] = useState({}); const { filters, onChangeFilter, onClearFilter, resetFilters } = useRolesModalFilters(); - const { data: allRolesData, allRolesMapStructure } = useAllRolesData(); + const { data: allRolesData, allRolesMapStructure } = useAllRolesData({ tenantId }); useEffect(() => { setAssignedRoleIds(initialRoleIds); diff --git a/src/components/Wrappers/withUserRoles.js b/src/components/Wrappers/withUserRoles.js index 5aa155b69..cb506f3a4 100644 --- a/src/components/Wrappers/withUserRoles.js +++ b/src/components/Wrappers/withUserRoles.js @@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react'; import { useStripes, useOkapiKy, useCallout } from '@folio/stripes/core'; import isEqual from 'lodash/isEqual'; +import isEmpty from 'lodash/isEmpty'; import { useCreateAuthUserKeycloak, useUserAffiliationRoles } from '../../hooks'; import { KEYCLOAK_USER_EXISTANCE } from '../../constants'; import { showErrorCallout } from '../../views/UserEdit/UserEditHelpers'; @@ -10,8 +11,9 @@ const withUserRoles = (WrappedComponent) => (props) => { const { okapi } = useStripes(); // eslint-disable-next-line react/prop-types const userId = props.match.params.id; - const initialAssignedRoleIds = useUserAffiliationRoles(userId); + const [initialAssignedRoleIds, setInitialAssignedRoleIds] = useState({}); const [tenantId, setTenantId] = useState(okapi.tenant); + const { userRoleIds, isLoading: isLoadingAffiliationRoles } = useUserAffiliationRoles(userId, tenantId); const [assignedRoleIds, setAssignedRoleIds] = useState({}); const [isCreateKeycloakUserConfirmationOpen, setIsCreateKeycloakUserConfirmationOpen] = useState(false); const callout = useCallout(); @@ -26,17 +28,33 @@ const withUserRoles = (WrappedComponent) => (props) => { } }); - const stringifiedInitialAssignedRoleIds = JSON.stringify(initialAssignedRoleIds); - useEffect(() => { - setAssignedRoleIds(initialAssignedRoleIds); - // on each re-render reference to initialAssignedRoleIds are different, so putting initialAssignedRoleIds to deps causes infinite trigger - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [stringifiedInitialAssignedRoleIds]); + // No need to set roles if there are empty or loading + if (!userRoleIds.length) return; + + setInitialAssignedRoleIds(prev => ({ + ...prev, + [tenantId]: userRoleIds, + })); + + + // Set assigned roles only if they are not set for the tenant yet + if (isEmpty(assignedRoleIds[tenantId])) { + setAssignedRoleIds(prev => ({ + ...prev, + [tenantId]: userRoleIds, + })); + } + }, [userRoleIds]); const updateUserRoles = async (roleIds) => { // to update roles for different tenants, we need to make API requests for each tenant const requests = Object.keys(roleIds).map((tenantIdKey) => { + // No need to make API call if roles didn't change for the tenant + if (isEqual(roleIds[tenantIdKey], initialAssignedRoleIds[tenantIdKey])) { + return Promise.resolve(); + } + const putApi = ky.extend({ hooks: { beforeRequest: [(req) => req.headers.set('X-Okapi-Tenant', tenantIdKey)] @@ -131,6 +149,7 @@ const withUserRoles = (WrappedComponent) => (props) => { initialAssignedRoleIds={initialAssignedRoleIds} checkAndHandleKeycloakAuthUser={checkAndHandleKeycloakAuthUser} confirmCreateKeycloakUser={confirmCreateKeycloakUser} + isLoadingAffiliationRoles={isLoadingAffiliationRoles} />; }; diff --git a/src/hooks/useAllRolesData/useAllRolesData.js b/src/hooks/useAllRolesData/useAllRolesData.js index 913a8cf14..918934dbc 100644 --- a/src/hooks/useAllRolesData/useAllRolesData.js +++ b/src/hooks/useAllRolesData/useAllRolesData.js @@ -14,15 +14,18 @@ import { useQuery } from 'react-query'; */ function useAllRolesData(options = {}) { - const { tenantId } = options; + const { + tenantId, + enabled = true, + } = options; const stripes = useStripes(); const ky = useOkapiKy({ tenant: tenantId || stripes.okapi.tenant }); - const [namespace] = useNamespace(); + const [namespace] = useNamespace({ key: 'tenant-roles' }); - const { data, isLoading, isSuccess, refetch } = useQuery([namespace, 'user-roles'], () => { + const { data, isLoading, isSuccess, refetch, isFetching } = useQuery([namespace, tenantId], () => { return ky.get(`roles?limit=${stripes.config.maxUnpagedResourceCount}&query=cql.allRecords=1 sortby name`).json(); - }, { enabled: stripes.hasInterface('roles') }); + }, { enabled: stripes.hasInterface('roles') && enabled }); const allRolesMapStructure = useMemo(() => { const rolesMap = new Map(); @@ -33,7 +36,7 @@ function useAllRolesData(options = {}) { return rolesMap; }, [data]); - return { data, isLoading, allRolesMapStructure, isSuccess, refetch }; + return { data, isLoading, allRolesMapStructure, isSuccess, refetch, isFetching }; } export default useAllRolesData; diff --git a/src/hooks/useUserAffiliationRoles/useUserAffiliationRoles.js b/src/hooks/useUserAffiliationRoles/useUserAffiliationRoles.js index 719d53460..729d2500a 100644 --- a/src/hooks/useUserAffiliationRoles/useUserAffiliationRoles.js +++ b/src/hooks/useUserAffiliationRoles/useUserAffiliationRoles.js @@ -1,67 +1,51 @@ -import { useStripes, useOkapiKy } from '@folio/stripes/core'; -import { useQueries } from 'react-query'; +import { useMemo } from 'react'; +import { useQuery } from 'react-query'; -function useUserAffiliationRoles(userId) { +import { useStripes, useOkapiKy, useNamespace } from '@folio/stripes/core'; + +import useAllRolesData from '../useAllRolesData/useAllRolesData'; + +const DEFAULT = []; + +function useUserAffiliationRoles(userId, tenantId) { const stripes = useStripes(); + const [namespace] = useNamespace({ key: 'user-affiliation-roles' }); + const ky = useOkapiKy({ tenant: tenantId }); - const searchParams = { - limit: stripes.config.maxUnpagedResourceCount, - query: `userId==${userId}`, - }; + const hasViewRolesPermission = stripes.hasPerm('ui-users.roles.view'); - // To unify in case if consortium of non-consortium - let tenants = stripes.user.user?.tenants || [{ id: stripes.okapi.tenant }]; - // Only make API calls if user has permission to view roles - tenants = stripes.hasPerm('ui-users.roles.view') ? tenants : []; - const ky = useOkapiKy(); + const { + isLoading: isAllRolesDataLoading, + isFetching: isAllRolesDataFetching, + allRolesMapStructure, + } = useAllRolesData({ tenantId, enabled: hasViewRolesPermission }); - const userTenantRolesQueries = useQueries( - tenants.map(({ id }) => { - return { - queryKey:['userTenantRoles', id], - queryFn:() => { - const api = ky.extend({ - hooks: { - beforeRequest: [(req) => req.headers.set('X-Okapi-Tenant', id)] - } - }); - return api.get('roles/users', { searchParams }).json(); - }, - enabled: Boolean(userId) - }; - }) + const { + data, + isLoading: isUserRolesLoading, + isFetching: isUserRolesFetching, + } = useQuery( + [namespace, userId, tenantId], + () => ky.get(`roles/users/${userId}`).json(), + { + enabled: Boolean(userId && tenantId && hasViewRolesPermission), + } ); - // Since `roles/users` return doesn't include names (only ids) for the roles, and we need them sorted by role name, - // we need to retrieve all the records for roles and use them to determine the sequence of ids. - const tenantRolesQueries = useQueries( - tenants.map(({ id }) => { - return { - queryKey:['tenantRolesAllRecords', id], - queryFn:() => { - const api = ky.extend({ - hooks: { - beforeRequest: [(req) => req.headers.set('X-Okapi-Tenant', id)] - } - }); - return api.get(`roles?limit=${stripes.config.maxUnpagedResourceCount}&query=cql.allRecords=1 sortby name`).json(); - }, - }; - }) - ); + const userRoleIds = useMemo(() => { + if (!data?.userRoles || !allRolesMapStructure.size) return DEFAULT; + + return data.userRoles + .map(({ roleId }) => allRolesMapStructure.get(roleId)) + .toSorted((a, b) => a.name.localeCompare(b.name)) + .map(({ id }) => id); + }, [data?.userRoles, allRolesMapStructure]); - // result from useQueries doesn’t provide information about the tenants, reach appropriate tenant using index - // useQueries guarantees that the results come in the same order as provided [queryFns] - return tenants.reduce((acc, tenant, index) => { - const roleIds = userTenantRolesQueries[index].data?.userRoles.map(d => d.roleId) || []; - const assignedRoles = []; - roleIds.forEach(roleId => { - const found = tenantRolesQueries[index].data?.roles.find(r => r.id === roleId); - if (found) assignedRoles.push(found); - }); - acc[tenant.id] = [...assignedRoles].sort((a, b) => a.name.localeCompare(b.name)).map(({ id }) => id); - return acc; - }, {}); + return { + userRoleIds, + isLoading: isUserRolesLoading || isAllRolesDataLoading, + isFetching: isUserRolesFetching || isAllRolesDataFetching, + }; } export default useUserAffiliationRoles; diff --git a/src/views/UserEdit/UserEdit.js b/src/views/UserEdit/UserEdit.js index 4f334f186..8c9011232 100644 --- a/src/views/UserEdit/UserEdit.js +++ b/src/views/UserEdit/UserEdit.js @@ -462,6 +462,7 @@ class UserEdit extends React.Component { setTenantId, tenantId, setAssignedRoleIds, + isLoadingAffiliationRoles, assignedRoleIds } = this.props; @@ -503,6 +504,7 @@ class UserEdit extends React.Component { tenantId={tenantId} setAssignedRoleIds={setAssignedRoleIds} assignedRoleIds={assignedRoleIds} + isLoadingAffiliationRoles={isLoadingAffiliationRoles} /> ); } diff --git a/src/views/UserEdit/UserForm.js b/src/views/UserEdit/UserForm.js index a1f2689b2..bd025bfcc 100644 --- a/src/views/UserEdit/UserForm.js +++ b/src/views/UserEdit/UserForm.js @@ -336,6 +336,7 @@ class UserForm extends React.Component { uniquenessValidator, profilePictureConfig, isCreateKeycloakUserConfirmationOpen, + isLoadingAffiliationRoles, onCancelKeycloakConfirmation } = this.props; const selectedPatronGroup = form.getFieldState('patronGroup')?.value; @@ -485,6 +486,8 @@ class UserForm extends React.Component { setAssignedRoleIds={this.props.setAssignedRoleIds} assignedRoleIds={this.props.assignedRoleIds} accordionId="userRoles" + initialAssignedRoleIds={initialValues.assignedRoleIds} + isLoadingAffiliationRoles={isLoadingAffiliationRoles} /> } @@ -542,4 +545,5 @@ export default stripesFinalForm({ initialValuesEqual: (a, b) => isEqual(a, b), navigationCheck: true, enableReinitialize: true, + keepDirtyOnReinitialize: true, })(injectIntl(UserForm));