Merge pull request #10292 from mabashian/8824-role-modal

Fixes bug where user/team role add modal state is not cleared on close

SUMMARY
link #8824
I modeled these changes after the pattern that existed between ResourceAccessList and AddResourceRole.  The key to fixing this bug is that the component that renders the wizard needs to be unmounted when the wizard closes so that the state, etc get cleared out before the next time the wizard is opened.  In order to achieve that I needed to decouple the add button from the wizard.
The sort of weird part of this pattern (and this exists in the ResourceAccessList as well) is error handling.  We pass the error back and set that to state before rendering the modal which isn't quite as clean as having the request made out at the list level and leveraging our hooks for error handling but I decided to just get in and get out and not worry about trying to refactor too much.
Here it is in action:

ISSUE TYPE

Bugfix Pull Request

COMPONENT NAME

UI

Reviewed-by: Kersom <None>
This commit is contained in:
softwarefactory-project-zuul[bot]
2021-06-03 15:52:40 +00:00
committed by GitHub
6 changed files with 101 additions and 96 deletions

View File

@@ -100,7 +100,9 @@ function SelectResourceStep({
headerRow={ headerRow={
<HeaderRow qsConfig={QS_Config(sortColumns)}> <HeaderRow qsConfig={QS_Config(sortColumns)}>
{sortColumns.map(({ name, key }) => ( {sortColumns.map(({ name, key }) => (
<HeaderCell sortKey={key}>{name}</HeaderCell> <HeaderCell sortKey={key} key={key}>
{name}
</HeaderCell>
))} ))}
</HeaderRow> </HeaderRow>
} }

View File

@@ -43,7 +43,7 @@ const CheckboxListItem = ({
{columns?.length > 0 ? ( {columns?.length > 0 ? (
columns.map(col => ( columns.map(col => (
<Td aria-label={col.name} dataLabel={col.key}> <Td aria-label={col.name} dataLabel={col.key} key={col.key}>
{item[col.key]} {item[col.key]}
</Td> </Td>
)) ))

View File

@@ -1,14 +1,9 @@
import React, { useState, useCallback, useEffect, useContext } from 'react'; import React, { useState, useCallback } from 'react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import styled from 'styled-components'; import styled from 'styled-components';
import { Button, DropdownItem } from '@patternfly/react-core'; import useRequest from '../../util/useRequest';
import { KebabifiedContext } from '../../contexts/Kebabified';
import useRequest, { useDismissableError } from '../../util/useRequest';
import SelectableCard from '../SelectableCard'; import SelectableCard from '../SelectableCard';
import AlertModal from '../AlertModal';
import ErrorDetail from '../ErrorDetail';
import Wizard from '../Wizard/Wizard'; import Wizard from '../Wizard/Wizard';
import useSelected from '../../util/useSelected'; import useSelected from '../../util/useSelected';
import SelectResourceStep from '../AddRole/SelectResourceStep'; import SelectResourceStep from '../AddRole/SelectResourceStep';
@@ -22,21 +17,20 @@ const Grid = styled.div`
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
`; `;
function UserAndTeamAccessAdd({ title, onFetchData, apiModel }) { function UserAndTeamAccessAdd({
title,
onFetchData,
apiModel,
onClose,
onError,
}) {
const [selectedResourceType, setSelectedResourceType] = useState(null); const [selectedResourceType, setSelectedResourceType] = useState(null);
const [isWizardOpen, setIsWizardOpen] = useState(false);
const [stepIdReached, setStepIdReached] = useState(1); const [stepIdReached, setStepIdReached] = useState(1);
const { id: userId } = useParams(); const { id: userId } = useParams();
const { isKebabified, onKebabModalChange } = useContext(KebabifiedContext);
const { const {
selected: resourcesSelected, selected: resourcesSelected,
handleSelect: handleResourceSelect, handleSelect: handleResourceSelect,
} = useSelected([]); } = useSelected([]);
useEffect(() => {
if (isKebabified) {
onKebabModalChange(isWizardOpen);
}
}, [isKebabified, isWizardOpen, onKebabModalChange]);
const { const {
selected: rolesSelected, selected: rolesSelected,
@@ -60,13 +54,10 @@ function UserAndTeamAccessAdd({ title, onFetchData, apiModel }) {
await Promise.all(roleRequests); await Promise.all(roleRequests);
onFetchData(); onFetchData();
setIsWizardOpen(false);
}, [onFetchData, rolesSelected, apiModel, userId, resourcesSelected]), }, [onFetchData, rolesSelected, apiModel, userId, resourcesSelected]),
{} {}
); );
const { error, dismissError } = useDismissableError(saveError);
const steps = [ const steps = [
{ {
id: 1, id: 1,
@@ -128,56 +119,22 @@ function UserAndTeamAccessAdd({ title, onFetchData, apiModel }) {
}, },
]; ];
if (error) { if (saveError) {
return ( onError(saveError);
<AlertModal onClose();
aria-label={t`Associate role error`}
isOpen={error}
variant="error"
title={t`Error!`}
onClose={dismissError}
>
{t`Failed to associate role`}
<ErrorDetail error={error} />
</AlertModal>
);
} }
return ( return (
<> <Wizard
{isKebabified ? ( isOpen
<DropdownItem title={title}
key="add" steps={steps}
component="button" onClose={onClose}
aria-label={t`Add`} onNext={({ id }) =>
onClick={() => setIsWizardOpen(true)} setStepIdReached(stepIdReached < id ? id : stepIdReached)
> }
{t`Add`} onSave={handleWizardSave}
</DropdownItem> />
) : (
<Button
ouiaId="access-add-button"
variant="primary"
aria-label={t`Add`}
onClick={() => setIsWizardOpen(true)}
key="add"
>
{t`Add`}
</Button>
)}
{isWizardOpen && (
<Wizard
isOpen={isWizardOpen}
title={title}
steps={steps}
onClose={() => setIsWizardOpen(false)}
onNext={({ id }) =>
setStepIdReached(stepIdReached < id ? id : stepIdReached)
}
onSave={handleWizardSave}
/>
)}
</>
); );
} }

View File

@@ -9,6 +9,9 @@ import UserAndTeamAccessAdd from './UserAndTeamAccessAdd';
jest.mock('../../api'); jest.mock('../../api');
const onError = jest.fn();
const onClose = jest.fn();
describe('<UserAndTeamAccessAdd/>', () => { describe('<UserAndTeamAccessAdd/>', () => {
const resources = { const resources = {
data: { data: {
@@ -57,24 +60,21 @@ describe('<UserAndTeamAccessAdd/>', () => {
<UserAndTeamAccessAdd <UserAndTeamAccessAdd
apiModel={UsersAPI} apiModel={UsersAPI}
onFetchData={() => {}} onFetchData={() => {}}
onClose={onClose}
title="Add user permissions" title="Add user permissions"
onError={onError}
/> />
); );
}); });
await waitForElement(wrapper, 'Button[aria-label="Add"]'); wrapper.update();
}); });
afterEach(() => { afterEach(() => {
jest.resetAllMocks(); jest.resetAllMocks();
}); });
test('should mount properly', async () => { test('should mount properly', async () => {
expect(wrapper.find('Button[aria-label="Add"]').length).toBe(1);
act(() => wrapper.find('Button[aria-label="Add"]').prop('onClick')());
wrapper.update();
expect(wrapper.find('PFWizard').length).toBe(1); expect(wrapper.find('PFWizard').length).toBe(1);
}); });
test('should disable steps', async () => { test('should disable steps', async () => {
act(() => wrapper.find('Button[aria-label="Add"]').prop('onClick')());
wrapper.update();
expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(true); expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(true);
expect( expect(
wrapper wrapper
@@ -122,8 +122,6 @@ describe('<UserAndTeamAccessAdd/>', () => {
JobTemplatesAPI.read.mockResolvedValue(resources); JobTemplatesAPI.read.mockResolvedValue(resources);
JobTemplatesAPI.readOptions.mockResolvedValue(options); JobTemplatesAPI.readOptions.mockResolvedValue(options);
UsersAPI.associateRole.mockResolvedValue({}); UsersAPI.associateRole.mockResolvedValue({});
act(() => wrapper.find('Button[aria-label="Add"]').prop('onClick')());
wrapper.update();
await act(async () => await act(async () =>
wrapper.find('SelectableCard[label="Job templates"]').prop('onClick')({ wrapper.find('SelectableCard[label="Job templates"]').prop('onClick')({
fetchItems: JobTemplatesAPI.read, fetchItems: JobTemplatesAPI.read,
@@ -179,15 +177,16 @@ describe('<UserAndTeamAccessAdd/>', () => {
await expect(UsersAPI.associateRole).toHaveBeenCalled(); await expect(UsersAPI.associateRole).toHaveBeenCalled();
}); });
test('should close wizard', async () => { test('should close wizard on cancel', async () => {
act(() => wrapper.find('Button[aria-label="Add"]').prop('onClick')()); await act(async () =>
wrapper.find('Button[children="Cancel"]').prop('onClick')()
);
wrapper.update(); wrapper.update();
act(() => wrapper.find('PFWizard').prop('onClose')()); expect(onClose).toHaveBeenCalledTimes(1);
wrapper.update();
expect(wrapper.find('PFWizard').length).toBe(0);
}); });
test('should throw error', async () => { test('should throw error', async () => {
expect(onError).toHaveBeenCalledTimes(0);
JobTemplatesAPI.read.mockResolvedValue(resources); JobTemplatesAPI.read.mockResolvedValue(resources);
JobTemplatesAPI.readOptions.mockResolvedValue(options); JobTemplatesAPI.readOptions.mockResolvedValue(options);
UsersAPI.associateRole.mockRejectedValue( UsersAPI.associateRole.mockRejectedValue(
@@ -210,9 +209,6 @@ describe('<UserAndTeamAccessAdd/>', () => {
}), }),
})); }));
act(() => wrapper.find('Button[aria-label="Add"]').prop('onClick')());
wrapper.update();
await act(async () => await act(async () =>
wrapper.find('SelectableCard[label="Job templates"]').prop('onClick')({ wrapper.find('SelectableCard[label="Job templates"]').prop('onClick')({
fetchItems: JobTemplatesAPI.read, fetchItems: JobTemplatesAPI.read,
@@ -261,6 +257,6 @@ describe('<UserAndTeamAccessAdd/>', () => {
await expect(UsersAPI.associateRole).toHaveBeenCalled(); await expect(UsersAPI.associateRole).toHaveBeenCalled();
wrapper.update(); wrapper.update();
expect(wrapper.find('AlertModal').length).toBe(1); expect(onError).toHaveBeenCalled();
}); });
}); });

View File

@@ -1,8 +1,6 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { import {
Button, Button,
EmptyState, EmptyState,
@@ -22,6 +20,7 @@ import { getQSConfig, parseQueryString } from '../../../util/qs';
import ErrorDetail from '../../../components/ErrorDetail'; import ErrorDetail from '../../../components/ErrorDetail';
import AlertModal from '../../../components/AlertModal'; import AlertModal from '../../../components/AlertModal';
import TeamRoleListItem from './TeamRoleListItem'; import TeamRoleListItem from './TeamRoleListItem';
import { ToolbarAddButton } from '../../../components/PaginatedDataList';
import UserAndTeamAccessAdd from '../../../components/UserAndTeamAccessAdd/UserAndTeamAccessAdd'; import UserAndTeamAccessAdd from '../../../components/UserAndTeamAccessAdd/UserAndTeamAccessAdd';
const QS_CONFIG = getQSConfig('roles', { const QS_CONFIG = getQSConfig('roles', {
@@ -33,6 +32,8 @@ const QS_CONFIG = getQSConfig('roles', {
function TeamRolesList({ me, team }) { function TeamRolesList({ me, team }) {
const { search } = useLocation(); const { search } = useLocation();
const [roleToDisassociate, setRoleToDisassociate] = useState(null); const [roleToDisassociate, setRoleToDisassociate] = useState(null);
const [showAddModal, setShowAddModal] = useState(false);
const [associateError, setAssociateError] = useState(null);
const { const {
isLoading, isLoading,
@@ -165,10 +166,10 @@ function TeamRolesList({ me, team }) {
additionalControls={[ additionalControls={[
...(canAdd ...(canAdd
? [ ? [
<UserAndTeamAccessAdd <ToolbarAddButton
apiModel={TeamsAPI} ouiaId="role-add-button"
onFetchData={fetchRoles} key="add"
title={t`Add team permissions`} onClick={() => setShowAddModal(true)}
/>, />,
] ]
: []), : []),
@@ -192,7 +193,18 @@ function TeamRolesList({ me, team }) {
/> />
)} )}
/> />
{showAddModal && (
<UserAndTeamAccessAdd
apiModel={TeamsAPI}
onFetchData={() => {
setShowAddModal(false);
fetchRoles();
}}
title={t`Add team permissions`}
onClose={() => setShowAddModal(false)}
onError={err => setAssociateError(err)}
/>
)}
{roleToDisassociate && ( {roleToDisassociate && (
<AlertModal <AlertModal
aria-label={t`Disassociate role`} aria-label={t`Disassociate role`}
@@ -228,6 +240,18 @@ function TeamRolesList({ me, team }) {
</div> </div>
</AlertModal> </AlertModal>
)} )}
{associateError && (
<AlertModal
aria-label={t`Associate role error`}
isOpen={associateError}
variant="error"
title={t`Error!`}
onClose={() => setAssociateError(null)}
>
{t`Failed to associate role`}
<ErrorDetail error={associateError} />
</AlertModal>
)}
{disassociationError && ( {disassociationError && (
<AlertModal <AlertModal
isOpen={disassociationError} isOpen={disassociationError}

View File

@@ -18,7 +18,7 @@ import PaginatedTable, {
} from '../../../components/PaginatedTable'; } from '../../../components/PaginatedTable';
import ErrorDetail from '../../../components/ErrorDetail'; import ErrorDetail from '../../../components/ErrorDetail';
import AlertModal from '../../../components/AlertModal'; import AlertModal from '../../../components/AlertModal';
import { ToolbarAddButton } from '../../../components/PaginatedDataList';
import DatalistToolbar from '../../../components/DataListToolbar'; import DatalistToolbar from '../../../components/DataListToolbar';
import UserRolesListItem from './UserRolesListItem'; import UserRolesListItem from './UserRolesListItem';
import UserAndTeamAccessAdd from '../../../components/UserAndTeamAccessAdd/UserAndTeamAccessAdd'; import UserAndTeamAccessAdd from '../../../components/UserAndTeamAccessAdd/UserAndTeamAccessAdd';
@@ -34,6 +34,8 @@ const QS_CONFIG = getQSConfig('roles', {
function UserRolesList({ user }) { function UserRolesList({ user }) {
const { search } = useLocation(); const { search } = useLocation();
const [roleToDisassociate, setRoleToDisassociate] = useState(null); const [roleToDisassociate, setRoleToDisassociate] = useState(null);
const [showAddModal, setShowAddModal] = useState(false);
const [associateError, setAssociateError] = useState(null);
const { const {
isLoading, isLoading,
@@ -178,10 +180,10 @@ function UserRolesList({ user }) {
additionalControls={[ additionalControls={[
...(canAdd ...(canAdd
? [ ? [
<UserAndTeamAccessAdd <ToolbarAddButton
apiModel={UsersAPI} ouiaId="role-add-button"
onFetchData={fetchRoles} key="add"
title={t`Add user permissions`} onClick={() => setShowAddModal(true)}
/>, />,
] ]
: []), : []),
@@ -189,6 +191,18 @@ function UserRolesList({ user }) {
/> />
)} )}
/> />
{showAddModal && (
<UserAndTeamAccessAdd
apiModel={UsersAPI}
onFetchData={() => {
setShowAddModal(false);
fetchRoles();
}}
title={t`Add user permissions`}
onClose={() => setShowAddModal(false)}
onError={err => setAssociateError(err)}
/>
)}
{roleToDisassociate && ( {roleToDisassociate && (
<AlertModal <AlertModal
aria-label={t`Disassociate role`} aria-label={t`Disassociate role`}
@@ -224,6 +238,18 @@ function UserRolesList({ user }) {
</div> </div>
</AlertModal> </AlertModal>
)} )}
{associateError && (
<AlertModal
aria-label={t`Associate role error`}
isOpen={associateError}
variant="error"
title={t`Error!`}
onClose={() => setAssociateError(null)}
>
{t`Failed to associate role`}
<ErrorDetail error={associateError} />
</AlertModal>
)}
{disassociationError && ( {disassociationError && (
<AlertModal <AlertModal
isOpen={disassociationError} isOpen={disassociationError}