From b11bff95940fa0f9824ab8709729626421f326ef Mon Sep 17 00:00:00 2001 From: Jaalah Ramos <125309814+jaalah-akamai@users.noreply.github.com> Date: Wed, 31 Jan 2024 08:24:40 -0500 Subject: [PATCH] feat: [M3-7490] - Improve NodeBalancer Restricted User Experience (#10095) Co-authored-by: Jaalah Ramos --- .../pr-10095-tech-stories-1706069306820.md | 5 +++ .../src/components/ActionMenu/ActionMenu.tsx | 14 ++++--- .../Button/StyledTagButton.stories.tsx | 37 ++++++++++++++++++ .../src/components/Button/StyledTagButton.ts | 38 +++++++++++++++++++ .../SelectFirewallPanel.tsx | 5 ++- .../src/components/TagCell/TagCell.tsx | 31 +++------------ .../components/TagsPanel/TagsPanel.styles.ts | 20 ---------- .../src/components/TagsPanel/TagsPanel.tsx | 16 +++++--- .../manager/src/features/Account/constants.ts | 13 +++++++ .../manager/src/features/Account/types.ts | 6 +++ .../manager/src/features/Account/utils.ts | 34 ++++++++++++++++- .../NodeBalancers/NodeBalancerCreate.tsx | 37 +++++++++++------- .../NodeBalancerConfigurations.tsx | 20 ++++++++++ .../NodeBalancerDetail/NodeBalancerDetail.tsx | 20 ++++++++++ .../NodeBalancerSettings.tsx | 12 +++++- .../NodeBalancerSummary/SummaryPanel.tsx | 10 ++++- .../NodeBalancerActionMenu.tsx | 18 ++++++++- .../NodeBalancerTableRow.tsx | 2 +- .../NodeBalancersLanding.tsx | 22 ++++++++++- .../NodeBalancersLandingEmptyState.tsx | 19 ++++++++++ .../BucketLanding/CreateBucketDrawer.tsx | 1 - .../BucketLanding/OMC_CreateBucketDrawer.tsx | 1 - .../src/features/Users/UserPermissions.tsx | 8 ++-- .../Users/UserPermissionsEntitySection.tsx | 16 +------- .../src/hooks/useIsResourceRestricted.ts | 26 +++++++++++++ 25 files changed, 331 insertions(+), 100 deletions(-) create mode 100644 packages/manager/.changeset/pr-10095-tech-stories-1706069306820.md create mode 100644 packages/manager/src/components/Button/StyledTagButton.stories.tsx create mode 100644 packages/manager/src/components/Button/StyledTagButton.ts create mode 100644 packages/manager/src/features/Account/types.ts create mode 100644 packages/manager/src/hooks/useIsResourceRestricted.ts diff --git a/packages/manager/.changeset/pr-10095-tech-stories-1706069306820.md b/packages/manager/.changeset/pr-10095-tech-stories-1706069306820.md new file mode 100644 index 00000000000..b9a124642db --- /dev/null +++ b/packages/manager/.changeset/pr-10095-tech-stories-1706069306820.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Improve NodeBalancer Restricted User Experience ([#10095](https://github.com/linode/manager/pull/10095)) diff --git a/packages/manager/src/components/ActionMenu/ActionMenu.tsx b/packages/manager/src/components/ActionMenu/ActionMenu.tsx index b450b853072..d37404cb53f 100644 --- a/packages/manager/src/components/ActionMenu/ActionMenu.tsx +++ b/packages/manager/src/components/ActionMenu/ActionMenu.tsx @@ -1,4 +1,4 @@ -import { IconButton, ListItemText } from '@mui/material'; +import { IconButton, ListItemText, useTheme } from '@mui/material'; import Menu from '@mui/material/Menu'; import MenuItem from '@mui/material/MenuItem'; import * as React from 'react'; @@ -37,6 +37,7 @@ export interface ActionMenuProps { */ export const ActionMenu = React.memo((props: ActionMenuProps) => { const { actionsList, ariaLabel, onOpen } = props; + const theme = useTheme(); const menuId = convertToKebabCase(ariaLabel); const buttonId = `${convertToKebabCase(ariaLabel)}-button`; @@ -69,14 +70,15 @@ export const ActionMenu = React.memo((props: ActionMenuProps) => { } const sxTooltipIcon = { - '& .MuiSvgIcon-root': { - fill: '#fff', - height: '20px', - width: '20px', - }, '& :hover': { color: '#4d99f1', }, + '&& .MuiSvgIcon-root': { + fill: theme.color.disabledText, + height: '20px', + width: '20px', + }, + color: '#fff', padding: '0 0 0 8px', pointerEvents: 'all', // Allows the tooltip to be hovered on a disabled MenuItem diff --git a/packages/manager/src/components/Button/StyledTagButton.stories.tsx b/packages/manager/src/components/Button/StyledTagButton.stories.tsx new file mode 100644 index 00000000000..843fb1f4bd9 --- /dev/null +++ b/packages/manager/src/components/Button/StyledTagButton.stories.tsx @@ -0,0 +1,37 @@ +import { action } from '@storybook/addon-actions'; +import React from 'react'; + +import { StyledPlusIcon, StyledTagButton } from './StyledTagButton'; + +import type { Meta, StoryObj } from '@storybook/react'; + +const meta: Meta = { + args: { + children: 'Tag', + disabled: false, + onClick: () => null, + }, + component: StyledTagButton, + title: 'Components/TagButton', +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + buttonType: 'outlined', + children: 'Tag', + disabled: false, + onClick: action('onClick'), + }, + render: (args) => ( + } + > + Add a Tag + + ), +}; diff --git a/packages/manager/src/components/Button/StyledTagButton.ts b/packages/manager/src/components/Button/StyledTagButton.ts new file mode 100644 index 00000000000..a35a6ef302a --- /dev/null +++ b/packages/manager/src/components/Button/StyledTagButton.ts @@ -0,0 +1,38 @@ +import { styled } from '@mui/material/styles'; + +import Plus from 'src/assets/icons/plusSign.svg'; + +import { Button } from './Button'; + +/** + * A button for Tags. Eventually this treatment will go away, + * but the sake of the MUI migration we need to keep it around for now, and as a styled component in order to get rid of + * spreading excessive styles for everywhere this is used. + * + */ +export const StyledTagButton = styled(Button, { + label: 'StyledTagButton', +})(({ theme, ...props }) => ({ + border: 'none', + fontSize: '0.875rem', + ...(!props.disabled && { + '&:hover, &:focus': { + backgroundColor: theme.color.tagButton, + border: 'none', + }, + backgroundColor: theme.color.tagButton, + color: theme.textColors.linkActiveLight, + }), +})); + +export const StyledPlusIcon = styled(Plus, { + label: 'StyledPlusIcon', +})(({ theme, ...props }) => ({ + color: props.disabled + ? theme.name === 'dark' + ? '#5c6470' + : theme.color.disabledText + : theme.color.tagIcon, + height: '10px', + width: '10px', +})); diff --git a/packages/manager/src/components/SelectFirewallPanel/SelectFirewallPanel.tsx b/packages/manager/src/components/SelectFirewallPanel/SelectFirewallPanel.tsx index 9ef30ea4ff5..cf38ac02edb 100644 --- a/packages/manager/src/components/SelectFirewallPanel/SelectFirewallPanel.tsx +++ b/packages/manager/src/components/SelectFirewallPanel/SelectFirewallPanel.tsx @@ -13,6 +13,7 @@ import { Autocomplete } from '../Autocomplete/Autocomplete'; import { LinkButton } from '../LinkButton'; interface Props { + disabled?: boolean; entityType: FirewallDeviceEntityType | undefined; handleFirewallChange: (firewallID: number) => void; helperText: JSX.Element; @@ -21,6 +22,7 @@ interface Props { export const SelectFirewallPanel = (props: Props) => { const { + disabled, entityType, handleFirewallChange, helperText, @@ -69,6 +71,7 @@ export const SelectFirewallPanel = (props: Props) => { onChange={(_, selection) => { handleFirewallChange(selection?.value ?? -1); }} + disabled={disabled} errorText={error?.[0].reason} label="Assign Firewall" loading={isLoading} @@ -78,7 +81,7 @@ export const SelectFirewallPanel = (props: Props) => { value={selectedFirewall} /> - + Create Firewall diff --git a/packages/manager/src/components/TagCell/TagCell.tsx b/packages/manager/src/components/TagCell/TagCell.tsx index 217fb603e7b..5e8393a5954 100644 --- a/packages/manager/src/components/TagCell/TagCell.tsx +++ b/packages/manager/src/components/TagCell/TagCell.tsx @@ -4,12 +4,12 @@ import { styled } from '@mui/material/styles'; import { SxProps } from '@mui/system'; import * as React from 'react'; -import Plus from 'src/assets/icons/plusSign.svg'; import { CircleProgress } from 'src/components/CircleProgress'; import { IconButton } from 'src/components/IconButton'; import { Tag } from 'src/components/Tag/Tag'; import { omittedProps } from 'src/utilities/omittedProps'; +import { StyledPlusIcon, StyledTagButton } from '../Button/StyledTagButton'; import { AddTag } from './AddTag'; interface TagCellProps { @@ -108,13 +108,14 @@ const TagCell = (props: TagCellProps) => { ) : null} - } onClick={() => setAddingTag(true)} title="Add a tag" > Add a tag - - + )} @@ -184,25 +185,3 @@ const StyledIconButton = styled(IconButton)(({ theme }) => ({ }, width: '40px', })); - -const StyledAddTagButton = styled('button')(({ theme }) => ({ - '& svg': { - color: theme.color.tagIcon, - height: 10, - marginLeft: 10, - width: 10, - }, - alignItems: 'center', - backgroundColor: theme.color.tagButton, - border: 'none', - borderRadius: 3, - color: theme.textColors.linkActiveLight, - cursor: 'pointer', - display: 'flex', - fontFamily: theme.font.bold, - fontSize: 14, - height: 30, - paddingLeft: 10, - paddingRight: 10, - whiteSpace: 'nowrap', -})); diff --git a/packages/manager/src/components/TagsPanel/TagsPanel.styles.ts b/packages/manager/src/components/TagsPanel/TagsPanel.styles.ts index af1bd19d7eb..68509434b4b 100644 --- a/packages/manager/src/components/TagsPanel/TagsPanel.styles.ts +++ b/packages/manager/src/components/TagsPanel/TagsPanel.styles.ts @@ -15,26 +15,6 @@ export const useStyles = makeStyles()((theme: Theme) => ({ justifyContent: 'flex-start', width: '100%', }, - addTagButton: { - '& svg': { - color: theme.color.tagIcon, - height: 10, - marginLeft: 10, - width: 10, - }, - alignItems: 'center', - backgroundColor: theme.color.tagButton, - border: 'none', - borderRadius: 3, - color: theme.textColors.linkActiveLight, - cursor: 'pointer', - display: 'flex', - fontFamily: theme.font.bold, - fontSize: '0.875rem', - justifyContent: 'center', - padding: '7px 10px', - whiteSpace: 'nowrap', - }, errorNotice: { '& .noticeText': { fontFamily: '"LatoWeb", sans-serif', diff --git a/packages/manager/src/components/TagsPanel/TagsPanel.tsx b/packages/manager/src/components/TagsPanel/TagsPanel.tsx index ba1fb36718d..be5b9e4388a 100644 --- a/packages/manager/src/components/TagsPanel/TagsPanel.tsx +++ b/packages/manager/src/components/TagsPanel/TagsPanel.tsx @@ -1,7 +1,10 @@ import * as React from 'react'; import { useQueryClient } from 'react-query'; -import Plus from 'src/assets/icons/plusSign.svg'; +import { + StyledPlusIcon, + StyledTagButton, +} from 'src/components/Button/StyledTagButton'; import { CircleProgress } from 'src/components/CircleProgress'; import Select from 'src/components/EnhancedSelect/Select'; import { Tag } from 'src/components/Tag/Tag'; @@ -162,6 +165,7 @@ export const TagsPanel = (props: TagsPanelProps) => { className={classes.selectTag} creatable createOptionPosition="first" + disabled={disabled} escapeClearsValue hideLabel isLoading={userTagsLoading} @@ -178,14 +182,14 @@ export const TagsPanel = (props: TagsPanelProps) => { [classes.hasError]: tagError.length > 0, })} > - + )}
diff --git a/packages/manager/src/features/Account/constants.ts b/packages/manager/src/features/Account/constants.ts index 71092b6607c..1ee5f799f61 100644 --- a/packages/manager/src/features/Account/constants.ts +++ b/packages/manager/src/features/Account/constants.ts @@ -1,6 +1,19 @@ export const BUSINESS_PARTNER = 'business partner'; export const ADMINISTRATOR = 'administrator'; +export const grantTypeMap = { + database: 'Databases', + domain: 'Domains', + firewall: 'Firewalls', + image: 'Images', + linode: 'Linodes', + longview: 'Longview Clients', + nodebalancer: 'NodeBalancers', + stackscript: 'StackScripts', + volume: 'Volumes', + vpc: 'VPCs', +} as const; + export const PARENT_PROXY_USER_CLOSE_ACCOUNT_TOOLTIP_TEXT = 'Remove indirect customers before closing the account.'; export const CHILD_USER_CLOSE_ACCOUNT_TOOLTIP_TEXT = diff --git a/packages/manager/src/features/Account/types.ts b/packages/manager/src/features/Account/types.ts new file mode 100644 index 00000000000..d2f4246087c --- /dev/null +++ b/packages/manager/src/features/Account/types.ts @@ -0,0 +1,6 @@ +import { grantTypeMap } from 'src/features/Account/constants'; + +// A useful type for getting the values of an object +export type ObjectValues = T[keyof T]; + +export type GrantTypeMap = ObjectValues; diff --git a/packages/manager/src/features/Account/utils.ts b/packages/manager/src/features/Account/utils.ts index 7d1052487f9..8d9e95cb13f 100644 --- a/packages/manager/src/features/Account/utils.ts +++ b/packages/manager/src/features/Account/utils.ts @@ -1,6 +1,38 @@ import { getStorage, setStorage } from 'src/utilities/storage'; -import type { Token } from '@linode/api-v4'; +import type { GlobalGrantTypes, Grants, Profile, Token } from '@linode/api-v4'; +import type { GrantTypeMap } from 'src/features/Account/types'; + +type ActionType = 'create' | 'delete' | 'edit' | 'view'; + +interface GetRestrictedResourceText { + action?: ActionType; + isSingular?: boolean; + resourceType: GrantTypeMap; +} +export const getRestrictedResourceText = ({ + action = 'edit', + isSingular = true, + resourceType, +}: GetRestrictedResourceText): string => { + const resource = isSingular + ? 'this ' + resourceType.replace(/s$/, '') + : resourceType; + + return `You don't have permissions to ${action} ${resource}. Please contact your account administrator to request the necessary permissions.`; +}; + +export const isRestrictedGlobalGrantType = ({ + globalGrantType, + grants, + profile, +}: { + globalGrantType: GlobalGrantTypes; + grants: Grants | undefined; + profile: Profile | undefined; +}): boolean => { + return Boolean(profile?.restricted) && !grants?.global[globalGrantType]; +}; // TODO: Parent/Child: FOR MSW ONLY, REMOVE WHEN API IS READY // ================================================================ diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx index bf464f0d5b7..99711c88d69 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx @@ -30,6 +30,10 @@ import { Tag, TagsInput } from 'src/components/TagsInput/TagsInput'; import { TextField } from 'src/components/TextField'; import { Typography } from 'src/components/Typography'; import { FIREWALL_GET_STARTED_LINK } from 'src/constants'; +import { + getRestrictedResourceText, + isRestrictedGlobalGrantType, +} from 'src/features/Account/utils'; import { useFlags } from 'src/hooks/useFlags'; import { reportAgreementSigningError, @@ -126,11 +130,14 @@ const NodeBalancerCreate = () => { const theme = useTheme(); const matchesSmDown = useMediaQuery(theme.breakpoints.down('md')); - const disabled = - Boolean(profile?.restricted) && !grants?.global.add_nodebalancers; + const isRestricted = isRestrictedGlobalGrantType({ + globalGrantType: 'add_nodebalancers', + grants, + profile, + }); const addNodeBalancer = () => { - if (disabled) { + if (isRestricted) { return; } setNodeBalancerFields((prev) => ({ @@ -457,16 +464,17 @@ const NodeBalancerCreate = () => { }} title="Create" /> - {generalError && !disabled && ( + {generalError && !isRestricted && ( {generalError} )} - {disabled && ( + {isRestricted && ( { )} { })) : [] } - disabled={disabled} + disabled={isRestricted} onChange={tagsChange} tagError={hasErrorFor('tags')} /> { Learn more. } + disabled={isRestricted} entityType="nodebalancer" selectedFirewallId={nodeBalancerFields.firewall_id ?? -1} /> @@ -575,7 +584,7 @@ const NodeBalancerCreate = () => { checkPassive={nodeBalancerFields.configs[idx].check_passive!} checkPath={nodeBalancerFields.configs[idx].check_path!} configIdx={idx} - disabled={disabled} + disabled={isRestricted} errors={nodeBalancerConfig.errors} healthCheckType={nodeBalancerFields.configs[idx].check!} nodeBalancerRegion={nodeBalancerFields.region} @@ -607,7 +616,7 @@ const NodeBalancerCreate = () => {