diff --git a/.gitignore b/.gitignore index 869601a..45d2797 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ # production /build +/translations/**/compiled/* # misc .DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index e19e7bd..7f60cf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,54 @@ # Change history for stripes-authorization-components -# 2.0.0 (IN PROGRESS) +# IN-PROGRESS + +* [UISAUTHCOM-97](https://folio-org.atlassian.net/browse/UISAUTHCOM-97) Add validation to RoleForm for Role Name. Disallowed forward slash "/" and required the name field for form submission. +* [UISAUTHCOM-96](https://folio-org.atlassian.net/browse/UISAUTHCOM-96) Add missing associated applications back to Role Edit view. + +# [2.2.0](https://github.com/folio-org/stripes-authorization-components/tree/v2.2.0) + +* [UISAUTHCOM-73](https://folio-org.atlassian.net/browse/UISAUTHCOM-73) Include capabilities actions when calculating counts for warning when de-selecting an application assigned to a role. +* [UISAUTHCOM-93](https://folio-org.atlassian.net/browse/UISAUTHCOM-93) Send full object body in PUT /roles request. +* [UIROLES-170](https://folio-org.atlassian.net/browse/UIROLES-170) Allow Save button to be enabled after first character entered in required `Role Name` field. +* [UISAUTHCOM-98](https://folio-org.atlassian.net/browse/UISAUTHCOM-98) Display warning and require confirmation before unchecking a capability set. + +# [2.1.0](https://github.com/folio-org/stripes-authorization-components/tree/v2.1.0) + +* [UISAUTHCOM-60](https://folio-org.atlassian.net/browse/UISAUTHCOM-60) Add new `hideUserLink` prop to `RoleDetails` component that will display users in assigned users list as a text if enabled. +* [UISAUTHCOM-59](https://folio-org.atlassian.net/browse/UISAUTHCOM-59) Increase request timeout in `useCreateRoleMutation`, `useEditRoleMutation` from default 30 seconds to 10 minutes. This can be decreased if back-end performance improves. +* [UISAUTHCOM-65](https://folio-org.atlassian.net/browse/UISAUTHCOM-65) Provide the ability to pass props to control whether certain actions can be performed. +* [UISAUTHCOM-66](https://folio-org.atlassian.net/browse/UISAUTHCOM-66) Suppress edit and delete menu buttons for default roles. +* [UISAUTHCOM-67](https://folio-org.atlassian.net/browse/UISAUTHCOM-67) Match GET role capabilities query key to match edit request, so `react-query` `invalidateQueries()` is called and new data is fetched. +* [UISAUTHCOM-68](https://folio-org.atlassian.net/browse/UISAUTHCOM-68) Group keycloak create errors together and ensure all user records in batch are attempted. +* [UISAUTHCOM-70](https://folio-org.atlassian.net/browse/UISAUTHCOM-70) Fix slowness when typing in name/description fields, by having component re-render onBlur instead of every keystroke. +* [UISAUTHCOM-69](https://folio-org.atlassian.net/browse/UISAUTHCOM-69) Display warning and require confirmation before unchecking an application for both Create and Edit Role. Rename confirmation button from "Okay" to "Continue". +* [UISAUTHCOM-72](https://folio-org.atlassian.net/browse/UISAUTHCOM-72) Pass `tenantId` prop to `` component on the role details pane. +* [UISAUTHCOM-73](https://folio-org.atlassian.net/browse/UISAUTHCOM-73) Include capabilities inside capability sets when calculating counts for warning when de-selecting an application assigned to a role. +* [UISAUTHCOM-73](https://folio-org.atlassian.net/browse/UISAUTHCOM-73) Fix capability calculations to de-dupe individual capabilities and those contained within capability sets, so correct value is shown when de-selecting an application assigned to a role. +* [UISAUTHCOM-76](https://folio-org.atlassian.net/browse/UISAUTHCOM-76) Update the message for sharing a role to indicate that it can be a lengthy process. +* [UISAUTHCOM-78](https://folio-org.atlassian.net/browse/UISAUTHCOM-78) Refactor timeout handling by introducing TIMEOUT constant for useOkapiKy in role sharing and delete role mutation hooks. +* [UISAUTHCOM-81](https://folio-org.atlassian.net/browse/UISAUTHCOM-81) Increase default timeout for role mutations. +* [UISAUTHCOM-74](https://folio-org.atlassian.net/browse/UISAUTHCOM-74) When previously saved capability sets are deselected, child capabilities are now deselected too. Also, ensure that deselecting a column of capability sets also deslects all child capabilities. +* [UISAUTHCOM-86](https://folio-org.atlassian.net/browse/UISAUTHCOM-86) Handling sharing of a role when an authorization role with the same name already exists in data tenant. +* [UISAUTHCOM-88](https://folio-org.atlassian.net/browse/UISAUTHCOM-88) Escape CQL values on sharing validation request. +* [UISAUTHCOM-87](https://folio-org.atlassian.net/browse/UISAUTHCOM-87) OmittheerrantwhitespacethattripsupPluggable's`children`'slengthcalculation. +* [UISAUTHCOM-83](https://folio-org.atlassian.net/browse/UISAUTHCOM-83) Preserve session-selected capabilities when unchecking a capability set that includes them. Keep `isInitialDataReady` false while fetching data to prevent data from being displayed after the page is reopened. +* [UISAUTHCOM-90](https://folio-org.atlassian.net/browse/UISAUTHCOM-90) Add validation to ensure role names are unique across tenants when editing shared authorization roles. + +# [2.0.2](https://github.com/folio-org/stripes-authorization-components/tree/v2.0.2) + +* [UISAUTHCOM-51](https://folio-org.atlassian.net/browse/UISAUTHCOM-51) Provide `expand=false` parameter to `useRoleCapabilities` that used in `useInitalRoleSharing` to correctly retrieve directly assigned capabilities + +# [2.0.1](https://github.com/folio-org/stripes-authorization-components/tree/v2.0.1) (2025-04-09) + +* [UISAUTHCOM-55](https://folio-org.atlassian.net/browse/UISAUTHCOM-55) Filter out any capabilities with property `dummyCapability = true` since they are invalid. API will suppress once MODROLESKC-285 is completed, but this immediately fixes the issue in the UI. + +# [2.0.0](https://github.com/folio-org/stripes-authorization-components/tree/v2.0.0) (2025-03-13) * *BREAKING* [UISAUTHCOM-46](https://folio-org.atlassian.net/browse/UISAUTHCOM-46) migrate react-intl to v7. -* *BREAKING* [UISAUTHCOM-50](https://folio-org.atlassian.net/browse/UISAUTHCOM-50) migrate stripes dependencies to their Sunflower versions. +* *BREAKING* [UISAUTHCOM-50](https://folio-org.atlassian.net/browse/UISAUTHCOM-50) migrate stripes dependencies to their Sunflower versions. -## 1.1.0 +## 1.1.0 * [UISAUTHCOM-44](https://folio-org.atlassian.net/browse/UISAUTHCOM-44) Add Deque Axe a11y tests. * [UISAUTHCOM-47](https://folio-org.atlassian.net/browse/UISAUTHCOM-47) Retry get publication details request for `404` response status. @@ -44,7 +87,7 @@ * [UISAUTHCOM-12](https://folio-org.atlassian.net/browse/UISAUTHCOM-12) Ensure support for the passed `tenantId` value for manipulations in the context of a specific tenant. * [UISAUTHCOM-17](https://folio-org.atlassian.net/browse/UISAUTHCOM-17) Create reusable components for editing, saving Authorization policies for the consortium. * [UISAUTHCOM-15](https://folio-org.atlassian.net/browse/UISAUTHCOM-15) Check if user exists in Keycloak on assign users to role. If not show confirmation dialog to create user records in Keycloak. -* [UISAUTHCOM-18](https://folio-org.atlassian.net/browse/UISAUTHCOM-18) Add button to unassign all assigned capabilities/sets when editing an authorization role in RoleForm. +* [UISAUTHCOM-18](https://folio-org.atlassian.net/browse/UISAUTHCOM-18) Add button to unassign all assigned capabilities/sets when editing an authorization role in RoleForm. * [UISAUTHCOM-19](https://folio-org.atlassian.net/browse/UISAUTHCOM-19) Create reusable hooks and components for duplicate authorization role. * [UISAUTHCOM-22](https://folio-org.atlassian.net/browse/UISAUTHCOM-22) Move "Select application" button to the top of the role form * [UISAUTHCOM-14](https://folio-org.atlassian.net/browse/UISAUTHCOM-14) ECS - Support sharing of authorization roles and policies. diff --git a/lib/Capabilities/CapabilitiesGrid/CapabilitiesGrid.js b/lib/Capabilities/CapabilitiesGrid/CapabilitiesGrid.js index 41b9373..aceb9ef 100644 --- a/lib/Capabilities/CapabilitiesGrid/CapabilitiesGrid.js +++ b/lib/Capabilities/CapabilitiesGrid/CapabilitiesGrid.js @@ -57,7 +57,7 @@ export const CapabilitiesGrid = ({ return
{formatMessage({ id:`stripes-authorization-components.columns.${action}` })} { - toggleCapabilitiesHeaderCheckbox(event, type, action); + toggleCapabilitiesHeaderCheckbox(event.target.checked, type, action); }} aria-label={formatMessage({ id:'stripes-authorization-components.columns.header.checkbox' }, { action })} />
; diff --git a/lib/Capabilities/CapabilitiesSetsAccordion/CapabilitiesSetsAccordion.js b/lib/Capabilities/CapabilitiesSetsAccordion/CapabilitiesSetsAccordion.js index 656667a..5aaca75 100644 --- a/lib/Capabilities/CapabilitiesSetsAccordion/CapabilitiesSetsAccordion.js +++ b/lib/Capabilities/CapabilitiesSetsAccordion/CapabilitiesSetsAccordion.js @@ -15,7 +15,8 @@ export const CapabilitiesSetsAccordion = ({ isCapabilitySetSelected, isLoading, onChangeCapabilitySetCheckbox, - toggleCapabilitySetsHeaderCheckbox, isAllActionCapabilitySetsSelected + toggleCapabilitySetsHeaderCheckbox, + isAllActionCapabilitySetsSelected }) => { return ( { - if (callback) { - callback(); - } + const unselectAllCapabilitiesAndSets = () => { + setSelectedCapabilitiesMap({}); + setSelectedCapabilitySetsMap({}); + setDisabledCapabilities({}); + }; + + const applyAppIdsChanges = (appIds) => { if (isEmpty(appIds)) { - setSelectedCapabilitiesMap({}); - setSelectedCapabilitySetsMap({}); - setDisabledCapabilities({}); + unselectAllCapabilitiesAndSets(); } setCheckedAppIdsMap(appIds); }; + const onSubmitSelectApplications = (selectedAppIds, onCloseHandler) => { + const unselectedAppInfo = changesForUnselect(selectedAppIds, checkedAppIdsMap, + capabilities, capabilitySetsList, selectedCapabilitiesMap, selectedCapabilitySetsMap); + + if (unselectedAppInfo.isConfirmationNeeded) { + setUnselectedItemsInfo({ ...unselectedAppInfo, selectedAppIds, onCloseHandler }); + setIsUnselectApplicationConfirmationOpen(true); + } else if (onCloseHandler) { // Changes can be applied directly if no capabilities or capability sets would be unselected + applyAppIdsChanges(selectedAppIds); + onCloseHandler(); + } + }; + return ( ); }; diff --git a/lib/Role/RoleCreate/RoleCreate.test.js b/lib/Role/RoleCreate/RoleCreate.test.js index 6368f2f..7ac6062 100644 --- a/lib/Role/RoleCreate/RoleCreate.test.js +++ b/lib/Role/RoleCreate/RoleCreate.test.js @@ -142,6 +142,10 @@ describe('RoleCreate', () => { afterEach(() => { jest.clearAllMocks(); }); + beforeEach(() => { + mockMutateRole.mockClear(); + }); + it('submit button', async () => { const { getByTestId, getByRole } = renderComponent(); @@ -153,6 +157,12 @@ describe('RoleCreate', () => { await act(async () => { await userEvent.type(getByTestId('rolename-input'), 'New Role'); + await userEvent.tab(); + }); + + expect(submitButton).toBeEnabled(); + + await act(async () => { await userEvent.click(submitButton); }); diff --git a/lib/Role/RoleEdit/RoleEdit.js b/lib/Role/RoleEdit/RoleEdit.js index 32d428d..f350190 100644 --- a/lib/Role/RoleEdit/RoleEdit.js +++ b/lib/Role/RoleEdit/RoleEdit.js @@ -1,5 +1,4 @@ -import isEmpty from 'lodash/isEmpty'; -import isEqual from 'lodash/isEqual'; +import { isEmpty, isEqual } from 'lodash'; import PropTypes from 'prop-types'; import { useEffect, useState } from 'react'; import { useQueryClient } from 'react-query'; @@ -21,7 +20,7 @@ import { } from '../../hooks'; import { isShared } from '../../utils'; import { RoleForm } from '../RoleForm'; -import { getCheckboxHandlers } from '../utils'; +import { getCheckboxHandlers, changesForUnselect } from '../utils'; export const RoleEdit = ({ path, tenantId }) => { const history = useHistory(); @@ -39,18 +38,48 @@ export const RoleEdit = ({ path, tenantId }) => { const { initialRoleCapabilitiesSelectedMap, isSuccess: isInitialRoleCapabilitiesLoaded, + isFetching: isRoleCapabilitiesFetching, capabilitiesAppIds - } = useRoleCapabilities(roleId, tenantId); + + // History: + // Previous changes applied expand=false which provided a convenient set of directly-assigned capabilities, + // but also removed certain applications from checkedAppsIdsMap that were only present via capability sets. + // + // Historical comment from UISAUTHCOM-83: https://folio-org.atlassian.net/browse/UISAUTHCOM-83 + // expand=false so the API returns only directly-assigned capabilities (not those inherited from + // capability sets). This keeps selectedCapabilitiesMap clean of set-owned capabilities, which + // allows us to correctly distinguish between capabilities that were loaded from saved state vs. + // capabilities manually selected in the current editing session. When a capability set is + // unchecked, only the initially-loaded capabilities are removed, preserving session selections. + // + // Changes applied with UISAUTHCOM-96: https://folio-org.atlassian.net/browse/UISAUTHCOM-96 + // expand=true pulls in capabilities that are present via capability set in addition to directly-assigned + // capabilities. + // This is needed so that owning applications of set-owned capabilities that appeared on the Role Detail page + // will not be excluded from this view. The data flows as follows: + // This 'expanded' response -> `capabilitiesAppIds` -> `checkedAppIdsMap` -> useApplicationCapabilities + // ultimately populate this view's capabilities table and check application checkboxes. + // + // The selectedCapabilitiesMap is now obtained by filtering out the set-owned capabilities + // from the now-expanded initialRoleCapabilitiesSelectedMap — see the isInitialDataReady + // effect below (directlyAssignedCapabilities), which is used to initialize the + // selectedCapabilitiesMap state. + // Check/uncheck behavior of Capability Sets is maintained in the current editing session, but currently does not persist - this was the acceptable + // trade-off for the related applications display. + } = useRoleCapabilities(roleId, tenantId, true); const [checkedAppIdsMap, setCheckedAppIdsMap] = useState({}); const [disabledCapabilities, setDisabledCapabilities] = useState({}); + const [isUnselectApplicationConfirmationOpen, setIsUnselectApplicationConfirmationOpen] = useState(false); + const [unselectedItemsInfo, setUnselectedItemsInfo] = useState({}); const { initialRoleCapabilitySetsSelectedMap, capabilitySetsCapabilities, isSuccess: isInitialRoleCapabilitySetsLoaded, + isFetching: isRoleCapabilitySetsFetching, capabilitySetsAppIds, - } = useRoleCapabilitySets(roleId, tenantId); + } = useRoleCapabilitySets(roleId, tenantId, true); const { capabilities, @@ -61,9 +90,11 @@ export const RoleEdit = ({ path, tenantId }) => { isLoading: isAppCapabilitiesLoading, queryKeys: applicationCapabilitiesQueryKeys, actionCapabilities - } = useApplicationCapabilities({ checkedAppIdsMap, + } = useApplicationCapabilities({ + checkedAppIdsMap, options: { tenantId }, - setDisabledCapabilities }); + setDisabledCapabilities + }); const { capabilitySets, @@ -75,8 +106,10 @@ export const RoleEdit = ({ path, tenantId }) => { isLoading: isAppCapabilitySetsLoading, queryKeys: applicationCapabilitySetsQueryKeys, actionCapabilitySets - } = useApplicationCapabilitySets({ checkedAppIdsMap, - options: { tenantId } }); + } = useApplicationCapabilitySets({ + checkedAppIdsMap, + options: { tenantId } + }); const unselectAllCapabilitiesAndSets = () => { setSelectedCapabilitiesMap({}); @@ -84,16 +117,26 @@ export const RoleEdit = ({ path, tenantId }) => { setDisabledCapabilities({}); }; - const onSubmitSelectApplications = (appIds, onCloseHandler) => { - if (onCloseHandler) { - onCloseHandler(); - } + const applyAppIdsChanges = (appIds) => { if (isEmpty(appIds)) { unselectAllCapabilitiesAndSets(); } setCheckedAppIdsMap(appIds); }; + const onSubmitSelectApplications = (selectedAppIds, onCloseHandler) => { + const unselectedAppInfo = changesForUnselect(selectedAppIds, checkedAppIdsMap, + capabilities, capabilitySetsList, selectedCapabilitiesMap, selectedCapabilitySetsMap); + + if (unselectedAppInfo.isConfirmationNeeded) { + setUnselectedItemsInfo({ ...unselectedAppInfo, selectedAppIds, onCloseHandler }); + setIsUnselectApplicationConfirmationOpen(true); + } else if (onCloseHandler) { // Changes can be applied directly if no capabilities or capability sets would be unselected + applyAppIdsChanges(selectedAppIds); + onCloseHandler(); + } + }; + const isRoleShared = Boolean(stripes.hasInterface('consortia') && isShared(roleDetails)); const shouldUpdateCapabilities = !isEqual(initialRoleCapabilitiesSelectedMap, selectedCapabilitiesMap); const shouldUpdateCapabilitySets = !isEqual(initialRoleCapabilitySetsSelectedMap, selectedCapabilitySetsMap); @@ -144,6 +187,8 @@ export const RoleEdit = ({ path, tenantId }) => { const roleData = { id: roleId, + type: roleDetails?.type, + metadata: roleDetails?.metadata, name: roleName, description, }; @@ -188,18 +233,29 @@ export const RoleEdit = ({ path, tenantId }) => { } }; - const isInitialDataReady = isInitialRoleCapabilitySetsLoaded && isInitialRoleCapabilitiesLoaded; + const isInitialDataReady = isInitialRoleCapabilitySetsLoaded && isInitialRoleCapabilitiesLoaded + && !isRoleCapabilitiesFetching && !isRoleCapabilitySetsFetching; const isLoading = isRoleMutating || isRoleSharing; useEffect(() => { if (isInitialDataReady) { + + // Filters out set-owned capabilities per the expand=true comment above + // useRoleCapabilities (UISAUTHCOM-96), so selectedCapabilitiesMap only ever holds + // capabilities that are directly assigned / manually chosen, matching + // disabledCapabilities as the single source of truth for set-owned capabilities. + const directlyAssignedCapabilities = Object.fromEntries( + Object.entries(initialRoleCapabilitiesSelectedMap) + .filter(([id]) => !capabilitySetsCapabilities[id]) + ); + // Define the selected applications and capability sets based on role ID // and installed applications. We update checkedAppIdsMap, // which triggers useChunkedApplicationCapabilities and useChunkedApplicationCapabilitySets // to fetch the actual data for the tables. setCheckedAppIdsMap({ ...capabilitiesAppIds, ...capabilitySetsAppIds }); - setSelectedCapabilitiesMap({ ...initialRoleCapabilitiesSelectedMap }); + setSelectedCapabilitiesMap(directlyAssignedCapabilities); setSelectedCapabilitySetsMap({ ...initialRoleCapabilitySetsSelectedMap }); setDisabledCapabilities({ ...capabilitySetsCapabilities }); } @@ -213,7 +269,10 @@ export const RoleEdit = ({ path, tenantId }) => { roleName={roleName} description={description} capabilities={capabilities} + disabledCapabilities={disabledCapabilities} capabilitySets={capabilitySets} + capabilitySetsList={capabilitySetsList} + actionCapabilitySets={actionCapabilitySets} checkedAppIdsMap={checkedAppIdsMap} isLoading={isLoading || !isInitialDataReady || !isRoleDetailsLoaded} isCapabilitySelected={isCapabilitySelected} @@ -225,6 +284,8 @@ export const RoleEdit = ({ path, tenantId }) => { onClose={onClose} onChangeCapabilityCheckbox={onChangeCapabilityCheckbox} onChangeCapabilitySetCheckbox={onChangeCapabilitySetCheckbox} + selectedCapabilitySetsMap={selectedCapabilitySetsMap} + selectedCapabilitiesMap={selectedCapabilitiesMap} onSaveSelectedApplications={onSubmitSelectApplications} isCapabilitiesLoading={isAppCapabilitiesLoading || !isInitialRoleCapabilitiesLoaded} isCapabilitySetsLoading={isAppCapabilitySetsLoading || !isInitialRoleCapabilitySetsLoaded} @@ -233,6 +294,10 @@ export const RoleEdit = ({ path, tenantId }) => { isAllActionCapabilitiesSelected={isAllActionCapabilitiesSelected} toggleCapabilitySetsHeaderCheckbox={toggleCapabilitySetsHeaderCheckbox} isAllActionCapabilitySetsSelected={isAllActionCapabilitySetsSelected} + isUnselectApplicationConfirmationOpen={isUnselectApplicationConfirmationOpen} + setIsUnselectApplicationConfirmationOpen={setIsUnselectApplicationConfirmationOpen} + unselectedItemsInfo={unselectedItemsInfo} + applyAppIdsChanges={applyAppIdsChanges} /> ); }; diff --git a/lib/Role/RoleEdit/RoleEdit.test.js b/lib/Role/RoleEdit/RoleEdit.test.js index c096652..b323084 100644 --- a/lib/Role/RoleEdit/RoleEdit.test.js +++ b/lib/Role/RoleEdit/RoleEdit.test.js @@ -257,6 +257,35 @@ describe('RoleEdit', () => { expect(getByTestId('pluggable-select-application')).toBeInTheDocument(); }); + it('should pass role metadata to useEditRoleMutation', () => { + const metadata = { + version: 1, + createdDate: '2024-01-01', + createdByUserId: 'user1', + updatedDate: '2024-01-02', + updatedByUserId: 'user2', + }; + + useRoleById.mockReturnValue({ + roleDetails: { + id: '1', + type: 'REGULAR', + name: 'Admin', + description: 'Description', + metadata, + }, + isSuccess: true, + }); + + renderComponent(); + + expect(useEditRoleMutation).toHaveBeenCalledWith( + expect.objectContaining({ metadata }), + expect.any(Object), + expect.any(Object), + ); + }); + it('should call capability/sets to local state on initial loading page, i.e. in useEffect', async () => { const { getAllByRole } = renderComponent(); @@ -441,6 +470,108 @@ describe('RoleEdit', () => { }); }); + describe('stale-while-revalidate guard', () => { + it('should NOT initialize selected maps while capabilities are still re-fetching (isFetching: true)', () => { + // Simulates the re-open-after-save scenario: react-query returns stale cache immediately + // with isSuccess:true but marks the query as isFetching:true while the fresh request is + // in-flight. Without the fix the init effect would fire with stale data, causing the + // newly-saved capability to appear unchecked until a full page reload. + useRoleCapabilities.mockReturnValue({ + initialRoleCapabilitiesSelectedMap: { 'cap-stale': true }, + isSuccess: true, + isFetching: true, // background refetch still in progress + capabilitiesAppIds: { 'app-platform-complete-0.0.5': true }, + }); + + renderComponent(); + + // isInitialDataReady must be false → init useEffect must not fire + expect(mockSetSelectedCapabilitiesMap).not.toHaveBeenCalled(); + expect(mockSetSelectedCapabilitySetsMap).not.toHaveBeenCalled(); + }); + + it('should NOT initialize selected maps while capability sets are still re-fetching (isFetching: true)', () => { + useRoleCapabilitySets.mockReturnValue({ + initialRoleCapabilitySetsSelectedMap: { 'set-stale': true }, + capabilitySetsCapabilities: {}, + isSuccess: true, + isFetching: true, // background refetch still in progress + capabilitySetsAppIds: { 'app-platform-complete-0.0.5': true }, + }); + + renderComponent(); + + expect(mockSetSelectedCapabilitiesMap).not.toHaveBeenCalled(); + expect(mockSetSelectedCapabilitySetsMap).not.toHaveBeenCalled(); + }); + + it('should initialize selected maps once both hooks finish fetching (isFetching: false)', () => { + const FRESH_CAP = 'cap-fresh-after-save'; + + useRoleCapabilities.mockReturnValue({ + initialRoleCapabilitiesSelectedMap: { [FRESH_CAP]: true }, + isSuccess: true, + isFetching: false, // refetch complete – fresh data available + capabilitiesAppIds: { 'app-platform-complete-0.0.5': true }, + }); + useRoleCapabilitySets.mockReturnValue({ + initialRoleCapabilitySetsSelectedMap: {}, + capabilitySetsCapabilities: {}, + isSuccess: true, + isFetching: false, + capabilitySetsAppIds: {}, + }); + + renderComponent(); + + expect(mockSetSelectedCapabilitiesMap).toHaveBeenCalledWith({ [FRESH_CAP]: true }); + }); + }); + + it('should call useRoleCapabilities with expand=true so set-owned capabilities/applications are loaded', () => { + renderComponent(); + + // tenantId is undefined because no tenantId prop is passed in this test + expect(useRoleCapabilities).toHaveBeenCalledWith( + expect.anything(), // roleId + undefined, // tenantId + true, // expand=true: also returns capabilities inherited from capability sets (UISAUTHCOM-96) + ); + }); + + it('should exclude set-owned capabilities from selectedCapabilitiesMap even though expand=true loads them', () => { + const CAP_DIRECT = '6e59c367-888a-4561-a3f3-3ca677de437f'; // directly assigned to role + const CAP_FROM_SET = 'cap-inherited-from-set-111'; // only comes from a capability set + + // useRoleCapabilities is mocked directly here, so the real expand value doesn't drive this + // test. What's under test is the isInitialDataReady effect's capabilitySetsCapabilities + // filter: CAP_FROM_SET must be excluded from selectedCapabilitiesMap even when it's present + // in initialRoleCapabilitiesSelectedMap (as it would be under expand=true). + useRoleCapabilities.mockReturnValue({ + initialRoleCapabilitiesSelectedMap: { [CAP_DIRECT]: true }, + isSuccess: true, + capabilitiesAppIds: { 'app-platform-complete-0.0.5': true }, + }); + + // The role also has a capability set whose capabilities include CAP_DIRECT and CAP_FROM_SET + useRoleCapabilitySets.mockReturnValue({ + initialRoleCapabilitySetsSelectedMap: { 'd2e91897-c10d-46f6-92df-dad77c1e8862': true }, + // capabilitySetsCapabilities tracks set-owned caps for disabledCapabilities, not selectedCapabilitiesMap + capabilitySetsCapabilities: { [CAP_DIRECT]: true, [CAP_FROM_SET]: true }, + isSuccess: true, + capabilitySetsAppIds: { 'app-platform-complete-0.0.5': true }, + }); + + renderComponent(); + + // selectedCapabilitiesMap must only contain caps returned by expand=false (directly assigned) + expect(mockSetSelectedCapabilitiesMap).toHaveBeenCalledWith({ [CAP_DIRECT]: true }); + // CAP_FROM_SET must NOT be in selectedCapabilitiesMap — it is tracked only in disabledCapabilities + expect(mockSetSelectedCapabilitiesMap).not.toHaveBeenCalledWith( + expect.objectContaining({ [CAP_FROM_SET]: true }), + ); + }); + it('has no a11y violations according to axe', async () => { expect.extend(toHaveNoViolations); diff --git a/lib/Role/RoleForm/RoleForm.js b/lib/Role/RoleForm/RoleForm.js index 60fbfd4..fd87b74 100644 --- a/lib/Role/RoleForm/RoleForm.js +++ b/lib/Role/RoleForm/RoleForm.js @@ -1,3 +1,4 @@ +import { useState } from 'react'; import PropTypes from 'prop-types'; import { FormattedMessage, useIntl } from 'react-intl'; @@ -5,6 +6,7 @@ import { Accordion, AccordionSet, AccordionStatus, Button, + ConfirmationModal, ExpandAllButton, Layer, Pane, @@ -20,6 +22,8 @@ import { CapabilitiesAccordion, CapabilitiesSetsAccordion, } from '../../Capabilities'; +import { getTypeActionCapSets, getUnselectedCapabilitySetCapabilities } from '../utils'; +import { validateNoSlashCharacter, validateRoleName } from '../../utils/validations'; import css from '../style.css'; @@ -39,6 +43,8 @@ export const RoleForm = ({ onSaveSelectedApplications, checkedAppIdsMap, capabilitySets, + capabilitySetsList, + actionCapabilitySets, isCapabilitySetSelected, onChangeCapabilitySetCheckbox, isCapabilityDisabled, @@ -48,8 +54,100 @@ export const RoleForm = ({ toggleCapabilitiesHeaderCheckbox, isAllActionCapabilitiesSelected, toggleCapabilitySetsHeaderCheckbox, - isAllActionCapabilitySetsSelected + isAllActionCapabilitySetsSelected, + isUnselectApplicationConfirmationOpen, + setIsUnselectApplicationConfirmationOpen, + unselectedItemsInfo, + applyAppIdsChanges }) => { + const [isUnselectCapabilitySetConfirmationOpen, setIsUnselectCapabilitySetConfirmationOpen] = useState({ + open: false, + capabilitySetIds: [], + unselectedCapabilityCount: 0, + checked: false, + type: null, + action: null + }); + + const [isNameBlurred, setIsNameBlurred] = useState(false); + + const isNameInvalid = !!validateRoleName(roleName); + + // Give the 'required' feedback only after the name field is blurred. + const nameError = isNameBlurred ? validateRoleName(roleName) : validateNoSlashCharacter(roleName); + + const handleSubmit = (event) => { + if (isNameInvalid) { + event.preventDefault(); + setIsNameBlurred(true); + return; + } + + onSubmit(event); + }; + + /** + * Handles the change event for the capability set checkbox. + * + * @param {object} event - The change event object from the checkbox. + * @param {string} capabilitySetId - The ID of the capability set associated with the checkbox. + */ + const handleCapabilitySetCheckbox = (event, capabilitySetId) => { + const { checked } = event.target; + + if (checked) { + onChangeCapabilitySetCheckbox(checked, capabilitySetId); + } else { + const unselectedAppInfo = getUnselectedCapabilitySetCapabilities(checkedAppIdsMap, capabilitySetsList, capabilities, [capabilitySetId], selectedCapabilitiesMap); + + setIsUnselectCapabilitySetConfirmationOpen({ + open: true, + capabilitySetIds: [capabilitySetId], + unselectedCapabilityCount: unselectedAppInfo.unselectedCapabilities?.size, + checked + }); + } + }; + + /** + * Handles the change event for the capability sets header checkbox. + * + * @param {boolean} checked - The checked state of the checkbox. + * @param {string} type - The type of the capability set. + * @param {string} action - The action of the capability set. + */ + const handleCapabilitySetsHeaderCheckbox = (checked, type, action) => { + if (checked) { + toggleCapabilitySetsHeaderCheckbox(checked, type, action); + } else { + const updatedCapabilitySetsMap = getTypeActionCapSets(actionCapabilitySets, type, action); + + const unselectedAppInfo = getUnselectedCapabilitySetCapabilities(checkedAppIdsMap, capabilitySetsList, capabilities, Object.keys(updatedCapabilitySetsMap), selectedCapabilitiesMap); + + setIsUnselectCapabilitySetConfirmationOpen({ + open: true, + capabilitySetIds: Object.keys(updatedCapabilitySetsMap), + unselectedCapabilityCount: unselectedAppInfo.unselectedCapabilities?.size, + checked, + type, + action + }); + } + }; + + /** + * Gets the display names of capability sets based on their IDs. + * + * @param {string[]} ids - An array of capability set IDs. + * @returns {string} A comma-separated string of display names. + */ + const getCapabilitySetDisplayNamesByIds = (ids) => { + return capabilitySetsList + .filter(capabilitySet => ids.includes(capabilitySet.id)) + .map(capabilitySet => `${capabilitySet.resource} - ${capabilitySet.action}`) + .join(', '); + }; + const paneFooterRenderStart = ( @@ -74,7 +172,7 @@ export const RoleForm = ({ const intl = useIntl(); - return
+ return } onChange={event => setRoleName(event.target.value)} + onBlur={() => setIsNameBlurred(true)} + onFocus={() => setIsNameBlurred(false)} + error={nameError} data-testid="rolename-input" />