Skip to content

Commit

Permalink
upcoming: [M3-7460] - Child View: Users & Grants page (#10076)
Browse files Browse the repository at this point in the history
* Add table and separate out partner users from all users

* Display Manage Access button for proxy users; label tables

* Rename variable and clean up

* Added changeset: Users & Grants: add business partner table to child view

* Display correct table cols; update variable names

* Adjust styling; properly feature flag table headings

* Disable Close Account button for child users

* Disable Close Account button for proxy users; add tests

* Add unit test for proxy user UserRow

* Switch notice to button tooltip

* Fix misleadingly passing Button.test.tsx

* Update test coverage for Close Account button tooltip

* Fix failing e2e test and add coverage for proxy user flow

* Use user_type from  and update tests

* Address feedback: center button with header; update mocks

* Address feedback: make table components

* Remove bad any type

* Address feedback: Add error, loading states to Close Account button

* Rely on parent user type, no need for childAccounts

* Address feedback: allow unrestricted proxy user to see themselves

* Fix typo

* Address feedback: filter users more efficiently with array reduce

* Make linter happy with sort imports

* Revert change to MSW for testing user_type
  • Loading branch information
mjac0bs authored Jan 29, 2024
1 parent e181795 commit d11cc59
Show file tree
Hide file tree
Showing 15 changed files with 480 additions and 120 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Upcoming Features
---

Users & Grants: add business partner table to child view ([#10076](https://github.com/linode/manager/pull/10076))
66 changes: 44 additions & 22 deletions packages/manager/cypress/e2e/core/account/user-permissions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -559,22 +559,30 @@ describe('User permission management', () => {
});
});

it('disables "Read Only" and "None" and defaults to "Read Write" Billing Access for "Proxy" account users with Parent/Child feature flag', () => {
const mockProfile = profileFactory.build({
/**
* Confirm the Users & Grants and User Permissions pages flow for a child account viewing a proxy user.
* Confirm that "Business partner settings" and "User settings" sections are present on the Users & Grants page.
* Confirm that proxy accounts are listed under "Business partner settings".
* Confirm that clicking the "Manage Access" button navigates to the proxy user's User Permissions page at /account/users/:user/permissions.
* Confirm that no "Profile" tab is present on the proxy user's User Permissions page.
* Confirm that proxy accounts default to "Read Write" Billing Access and have disabled "Read Only" and "None" options.
*/
it('tests the users landing and user permissions flow for a child account viewing a proxy user ', () => {
const mockChildProfile = profileFactory.build({
username: 'proxy-user',
user_type: 'child',
});

const mockActiveUser = accountUserFactory.build({
username: 'proxy-user',
const mockChildUser = accountUserFactory.build({
restricted: false,
user_type: 'proxy',
user_type: 'child',
});

const mockRestrictedUser = {
...mockActiveUser,
const mockRestrictedProxyUser = accountUserFactory.build({
restricted: true,
user_type: 'proxy',
username: 'restricted-proxy-user',
};
});

const mockUserGrants = grantsFactory.build({
global: { account_access: 'read_write' },
Expand All @@ -586,33 +594,47 @@ describe('User permission management', () => {
}).as('getFeatureFlags');
mockGetFeatureFlagClientstream().as('getClientStream');

mockGetUsers([mockActiveUser, mockRestrictedUser]).as('getUsers');
mockGetUser(mockActiveUser);
mockGetUserGrants(mockActiveUser.username, mockUserGrants);
mockGetProfile(mockProfile);
mockGetUser(mockRestrictedUser);
mockGetUserGrants(mockRestrictedUser.username, mockUserGrants);
mockGetUsers([mockRestrictedProxyUser]).as('getUsers');
mockGetUser(mockChildUser);
mockGetUserGrants(mockChildUser.username, mockUserGrants);
mockGetProfile(mockChildProfile);
mockGetUser(mockRestrictedProxyUser);
mockGetUserGrants(mockRestrictedProxyUser.username, mockUserGrants);

// Navigate to Users & Grants page, find mock restricted user, click its "User Permissions" button.
// Navigate to Users & Grants page and confirm "Business partner settings" and "User settings" sections are visible.
cy.visitWithLogin('/account/users');
cy.wait('@getUsers');
cy.findByText(mockRestrictedUser.username)
cy.findByText('Business partner settings').should('be.visible');
cy.findByText('User settings').should('be.visible');

// Find mock restricted proxy user under "Business partner settings", click its "Manage Access" button.
cy.findByLabelText('List of Business Partners')
.should('be.visible')
.closest('tr')
.within(() => {
ui.button
.findByTitle('User Permissions')
cy.findByText(mockRestrictedProxyUser.username)
.should('be.visible')
.should('be.enabled')
.click();
.closest('tr')
.within(() => {
ui.button
.findByTitle('Manage Access')
.should('be.visible')
.should('be.enabled')
.click();
});
});

// Confirm button navigates to the proxy user's User Permissions page at /account/users/:user/permissions.
cy.url().should(
'endWith',
`/account/users/${mockRestrictedUser.username}/permissions`
`/account/users/${mockRestrictedProxyUser.username}/permissions`
);
cy.wait(['@getClientStream', '@getFeatureFlags']);

cy.findByText('Business Partner Permissions').should('be.visible');

// Confirm that no "Profile" tab is present on the proxy user's User Permissions page.
expect(cy.findByText('User Profile').should('not.exist'));

cy.get('[data-qa-global-section]')
.should('be.visible')
.within(() => {
Expand Down
11 changes: 8 additions & 3 deletions packages/manager/src/components/Button/Button.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { fireEvent, screen } from '@testing-library/react';
import { fireEvent, screen, waitFor } from '@testing-library/react';
import React from 'react';

import { renderWithTheme } from 'src/utilities/testHelpers';
Expand Down Expand Up @@ -44,7 +44,7 @@ describe('Button', () => {

it('should display the tooltip if disabled and tooltipText is true', async () => {
const { getByTestId } = renderWithTheme(
<Button disabled tooltipText="Test">
<Button disabled tooltipText="Test tooltip">
Test
</Button>
);
Expand All @@ -53,6 +53,11 @@ describe('Button', () => {
expect(button).toHaveAttribute('aria-describedby', 'button-tooltip');

fireEvent.mouseOver(button);
await expect(screen.getByText('Test')).toBeInTheDocument();

await waitFor(() => {
expect(screen.getByRole('tooltip')).toBeInTheDocument();
});

expect(screen.getByText('Test tooltip')).toBeVisible();
});
});
89 changes: 75 additions & 14 deletions packages/manager/src/features/Account/CloseAccountSetting.test.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,25 @@
import { fireEvent, waitFor } from '@testing-library/react';
import * as React from 'react';

import { accountFactory } from 'src/factories';
import { makeResourcePage } from 'src/mocks/serverHandlers';
import { profileFactory } from 'src/factories';
import { renderWithTheme } from 'src/utilities/testHelpers';

import CloseAccountSetting from './CloseAccountSetting';
import {
CHILD_USER_CLOSE_ACCOUNT_TOOLTIP_TEXT,
PARENT_PROXY_USER_CLOSE_ACCOUNT_TOOLTIP_TEXT,
} from './constants';

// Mock the useChildAccounts hook to immediately return the expected data, circumventing the HTTP request and loading state.
// Mock the useProfile hook to immediately return the expected data, circumventing the HTTP request and loading state.
const queryMocks = vi.hoisted(() => ({
useChildAccounts: vi.fn().mockReturnValue({}),
useProfile: vi.fn().mockReturnValue({}),
}));

vi.mock('src/queries/account', async () => {
const actual = await vi.importActual<any>('src/queries/account');
vi.mock('src/queries/profile', async () => {
const actual = await vi.importActual('src/queries/profile');
return {
...actual,
useChildAccounts: queryMocks.useChildAccounts,
useProfile: queryMocks.useProfile,
};
});

Expand All @@ -34,25 +38,82 @@ describe('Close Account Settings', () => {
const button = getByTestId('close-account-button');
const span = button.querySelector('span');
expect(button).toBeInTheDocument();
expect(button).toBeEnabled();
expect(span).toHaveTextContent('Close Account');
});

it('should render a disabled Close Account button and helper text when there is at least one child account', () => {
queryMocks.useChildAccounts.mockReturnValue({
data: makeResourcePage(accountFactory.buildList(1)),
it('should render a disabled Close Account button with tooltip for a parent account user', async () => {
queryMocks.useProfile.mockReturnValue({
data: profileFactory.build({ user_type: 'parent' }),
});

const { getByTestId, getByText } = renderWithTheme(
const { getByRole, getByTestId, getByText } = renderWithTheme(
<CloseAccountSetting />,
{
flags: { parentChildAccountAccess: true },
}
);
const notice = getByText(
'Remove indirect customers before closing the account.'
const button = getByTestId('close-account-button');
fireEvent.mouseOver(button);

await waitFor(() => {
expect(getByRole('tooltip')).toBeInTheDocument();
});

expect(
getByText(PARENT_PROXY_USER_CLOSE_ACCOUNT_TOOLTIP_TEXT)
).toBeVisible();
expect(button).toHaveAttribute('aria-describedby', 'button-tooltip');
expect(button).not.toHaveAttribute('disabled');
expect(button).toHaveAttribute('aria-disabled', 'true');
});

it('should render a disabled Close Account button with tooltip for a child account user', async () => {
queryMocks.useProfile.mockReturnValue({
data: profileFactory.build({ user_type: 'child' }),
});

const { getByRole, getByTestId, getByText } = renderWithTheme(
<CloseAccountSetting />,
{
flags: { parentChildAccountAccess: true },
}
);
const button = getByTestId('close-account-button');
fireEvent.mouseOver(button);

await waitFor(() => {
expect(getByRole('tooltip')).toBeInTheDocument();
});

expect(getByText(CHILD_USER_CLOSE_ACCOUNT_TOOLTIP_TEXT)).toBeVisible();
expect(button).toHaveAttribute('aria-describedby', 'button-tooltip');
expect(button).not.toHaveAttribute('disabled');
expect(button).toHaveAttribute('aria-disabled', 'true');
});

it('should render a disabled Close Account button with tooltip for a proxy account user', async () => {
queryMocks.useProfile.mockReturnValue({
data: profileFactory.build({ user_type: 'proxy' }),
});

const { getByRole, getByTestId, getByText } = renderWithTheme(
<CloseAccountSetting />,
{
flags: { parentChildAccountAccess: true },
}
);
const button = getByTestId('close-account-button');
expect(notice).toBeInTheDocument();
fireEvent.mouseOver(button);

await waitFor(() => {
expect(getByRole('tooltip')).toBeInTheDocument();
});

expect(
getByText(PARENT_PROXY_USER_CLOSE_ACCOUNT_TOOLTIP_TEXT)
).toBeVisible();
expect(button).toHaveAttribute('aria-describedby', 'button-tooltip');
expect(button).not.toHaveAttribute('disabled');
expect(button).toHaveAttribute('aria-disabled', 'true');
});
Expand Down
28 changes: 17 additions & 11 deletions packages/manager/src/features/Account/CloseAccountSetting.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,35 +3,41 @@ import * as React from 'react';

import { Accordion } from 'src/components/Accordion';
import { Button } from 'src/components/Button/Button';
import { Notice } from 'src/components/Notice/Notice';
import { useFlags } from 'src/hooks/useFlags';
import { useChildAccounts } from 'src/queries/account';
import { useProfile } from 'src/queries/profile';

import CloseAccountDialog from './CloseAccountDialog';
import {
CHILD_USER_CLOSE_ACCOUNT_TOOLTIP_TEXT,
PARENT_PROXY_USER_CLOSE_ACCOUNT_TOOLTIP_TEXT,
} from './constants';

const CloseAccountSetting = () => {
const [dialogOpen, setDialogOpen] = React.useState<boolean>(false);

const { data: childAccounts } = useChildAccounts({});
const { data: profile } = useProfile();
const flags = useFlags();
const closeAccountDisabled =
flags.parentChildAccountAccess && Boolean(childAccounts?.data?.length);

// Disable the Close Account button for users with a Parent/Proxy/Child user type.
const isCloseAccountDisabled = Boolean(
flags.parentChildAccountAccess && profile?.user_type !== null
);
const closeAccountButtonTooltipText =
isCloseAccountDisabled && profile?.user_type === 'child'
? CHILD_USER_CLOSE_ACCOUNT_TOOLTIP_TEXT
: PARENT_PROXY_USER_CLOSE_ACCOUNT_TOOLTIP_TEXT;

return (
<>
<Accordion defaultExpanded={true} heading="Close Account">
<Grid container direction="column">
<Grid>
{closeAccountDisabled && (
<Notice spacingBottom={20} variant="info">
Remove indirect customers before closing the account.
</Notice>
)}
<Button
buttonType="outlined"
data-testid="close-account-button"
disabled={closeAccountDisabled}
disabled={isCloseAccountDisabled}
onClick={() => setDialogOpen(true)}
tooltipText={closeAccountButtonTooltipText}
>
Close Account
</Button>
Expand Down
5 changes: 5 additions & 0 deletions packages/manager/src/features/Account/constants.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,7 @@
export const BUSINESS_PARTNER = 'business partner';
export const ADMINISTRATOR = 'administrator';

export const PARENT_PROXY_USER_CLOSE_ACCOUNT_TOOLTIP_TEXT =
'Remove indirect customers before closing the account.';
export const CHILD_USER_CLOSE_ACCOUNT_TOOLTIP_TEXT =
'Contact your business partner to close your account.';
14 changes: 9 additions & 5 deletions packages/manager/src/features/Users/UserDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,20 @@ import { TabLinkList } from 'src/components/Tabs/TabLinkList';
import { TabPanels } from 'src/components/Tabs/TabPanels';
import { Tabs } from 'src/components/Tabs/Tabs';
import { queryKey } from 'src/queries/account';
import { useAccountUser } from 'src/queries/accountUsers';
import { useProfile } from 'src/queries/profile';
import { getAPIErrorOrDefault } from 'src/utilities/errorUtils';

import UserPermissions from './UserPermissions';
import { UserProfile } from './UserProfile';

export const UserDetail = () => {
const { username: usernameParam } = useParams<{ username: string }>();
const { username: currentUsername } = useParams<{ username: string }>();
const location = useLocation<{ newUsername: string; success: boolean }>();
const history = useHistory();

const { data: profile, refetch: refreshProfile } = useProfile();
const { data: user } = useAccountUser(currentUsername ?? '');

const queryClient = useQueryClient();

Expand Down Expand Up @@ -62,17 +64,17 @@ export const UserDetail = () => {
const tabs = [
/* NB: These must correspond to the routes inside the Switch */
{
routeName: `/account/users/${usernameParam}/profile`,
routeName: `/account/users/${currentUsername}/profile`,
title: 'User Profile',
},
{
routeName: `/account/users/${usernameParam}/permissions`,
routeName: `/account/users/${currentUsername}/permissions`,
title: 'User Permissions',
},
];

React.useEffect(() => {
getUser(usernameParam)
getUser(currentUsername)
.then((user) => {
setOriginalUsername(user.username);
setUsername(user.username);
Expand Down Expand Up @@ -193,6 +195,8 @@ export const UserDetail = () => {
history.push(tabs[index].routeName);
};

const isProxyUser = user?.user_type === 'proxy';

if (error) {
return (
<React.Fragment>
Expand Down Expand Up @@ -221,7 +225,7 @@ export const UserDetail = () => {
)}
onChange={navToURL}
>
<TabLinkList tabs={tabs} />
{!isProxyUser && <TabLinkList tabs={tabs} />}

{createdUsername && (
<Notice
Expand Down
3 changes: 2 additions & 1 deletion packages/manager/src/features/Users/UserPermissions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,7 @@ class UserPermissions extends React.Component<CombinedProps, State> {
const { errors, restricted } = this.state;
const hasErrorFor = getAPIErrorFor({ restricted: 'Restricted' }, errors);
const generalError = hasErrorFor('none');
const isProxyUser = this.state.userType === 'proxy';

return (
<Box sx={{ marginTop: (theme) => theme.spacing(4) }}>
Expand All @@ -442,7 +443,7 @@ class UserPermissions extends React.Component<CombinedProps, State> {
>
<StyledHeaderGrid>
<Typography data-qa-restrict-access={restricted} variant="h2">
General Permissions
{isProxyUser ? 'Business Partner' : 'General'} Permissions
</Typography>
</StyledHeaderGrid>
<StyledSubHeaderGrid>
Expand Down
Loading

0 comments on commit d11cc59

Please sign in to comment.