diff --git a/packages/manager/.changeset/pr-10227-tech-stories-1708986866364.md b/packages/manager/.changeset/pr-10227-tech-stories-1708986866364.md new file mode 100644 index 00000000000..e9cf74bfb9d --- /dev/null +++ b/packages/manager/.changeset/pr-10227-tech-stories-1708986866364.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Improve the Linodes restricted user experience ([#10227](https://github.com/linode/manager/pull/10227)) diff --git a/packages/manager/src/components/EmptyLandingPageResources/ResourcesSection.tsx b/packages/manager/src/components/EmptyLandingPageResources/ResourcesSection.tsx index 61ebc8a818f..33386afde6b 100644 --- a/packages/manager/src/components/EmptyLandingPageResources/ResourcesSection.tsx +++ b/packages/manager/src/components/EmptyLandingPageResources/ResourcesSection.tsx @@ -24,7 +24,9 @@ import type { interface ButtonProps { children: string; + disabled?: boolean; onClick: () => void; + tooltipText?: string; } interface ResourcesSectionProps { diff --git a/packages/manager/src/components/TagCell/TagCell.tsx b/packages/manager/src/components/TagCell/TagCell.tsx index ced0fd1b51b..cdccac6561c 100644 --- a/packages/manager/src/components/TagCell/TagCell.tsx +++ b/packages/manager/src/components/TagCell/TagCell.tsx @@ -13,6 +13,7 @@ import { StyledPlusIcon, StyledTagButton } from '../Button/StyledTagButton'; import { AddTag } from './AddTag'; interface TagCellProps { + disabled?: boolean; listAllTags: (tags: string[]) => void; sx?: SxProps; tags: string[]; @@ -35,7 +36,7 @@ const checkOverflow = (el: any) => { }; const TagCell = (props: TagCellProps) => { - const { sx, tags, updateTags } = props; + const { disabled, sx, tags, updateTags } = props; const [hasOverflow, setOverflow] = React.useState(false); const [addingTag, setAddingTag] = React.useState(false); @@ -90,6 +91,7 @@ const TagCell = (props: TagCellProps) => { {tags.map((thisTag) => ( { ) : null} } onClick={() => setAddingTag(true)} title="Add a tag" diff --git a/packages/manager/src/components/TagCell/TagDrawer.tsx b/packages/manager/src/components/TagCell/TagDrawer.tsx index 675f59b2bfe..19cff2b0900 100644 --- a/packages/manager/src/components/TagCell/TagDrawer.tsx +++ b/packages/manager/src/components/TagCell/TagDrawer.tsx @@ -15,11 +15,11 @@ export interface TagDrawerProps { } const TagDrawer = (props: TagDrawerProps) => { - const { entityLabel, onClose, open, tags, updateTags } = props; + const { entityID, entityLabel, onClose, open, tags, updateTags } = props; return ( - + ); }; diff --git a/packages/manager/src/components/TagsPanel/TagsPanel.test.tsx b/packages/manager/src/components/TagsPanel/TagsPanel.test.tsx index 16d172084ca..93ee05ff995 100644 --- a/packages/manager/src/components/TagsPanel/TagsPanel.test.tsx +++ b/packages/manager/src/components/TagsPanel/TagsPanel.test.tsx @@ -6,12 +6,17 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { TagsPanel } from './TagsPanel'; +const tagsPanelProps = { + entityId: 123, + tags: ['Tag1', 'Tag2'], +}; + describe('TagsPanel', () => { it('renders TagsPanel component with existing tags', async () => { const updateTagsMock = vi.fn(() => Promise.resolve()); const { getByLabelText, getByText } = renderWithTheme( - + ); expect(getByText('Tag1')).toBeInTheDocument(); @@ -30,7 +35,7 @@ describe('TagsPanel', () => { const updateTagsMock = vi.fn(() => Promise.resolve()); const { getByLabelText, getByText } = renderWithTheme( - + ); await userEvent.click(getByText('Add a tag')); @@ -51,7 +56,7 @@ describe('TagsPanel', () => { const updateTagsMock = vi.fn(() => Promise.resolve()); const { getByLabelText, getByText } = renderWithTheme( - + ); await userEvent.click(getByText('Add a tag')); @@ -75,7 +80,7 @@ describe('TagsPanel', () => { const updateTagsMock = vi.fn(() => Promise.resolve()); const { getByLabelText, getByText, queryByLabelText } = renderWithTheme( - + ); expect(getByText('Tag1')).toBeInTheDocument(); @@ -93,7 +98,7 @@ describe('TagsPanel', () => { const updateTagsMock = vi.fn(() => Promise.resolve()); const { getByText, queryByLabelText, queryByText } = renderWithTheme( - + ); expect(getByText('Tag1')).toBeInTheDocument(); diff --git a/packages/manager/src/components/TagsPanel/TagsPanel.tsx b/packages/manager/src/components/TagsPanel/TagsPanel.tsx index be5b9e4388a..465c2335fd9 100644 --- a/packages/manager/src/components/TagsPanel/TagsPanel.tsx +++ b/packages/manager/src/components/TagsPanel/TagsPanel.tsx @@ -9,6 +9,7 @@ import { CircleProgress } from 'src/components/CircleProgress'; import Select from 'src/components/EnhancedSelect/Select'; import { Tag } from 'src/components/Tag/Tag'; import { Typography } from 'src/components/Typography'; +import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; import { useProfile } from 'src/queries/profile'; import { updateTagsSuggestionsData, useTagSuggestions } from 'src/queries/tags'; import { getErrorStringOrDefault } from 'src/utilities/errorUtils'; @@ -33,6 +34,10 @@ export interface TagsPanelProps { * If true, the input will be disabled and no tags can be added or removed. */ disabled?: boolean; + /** + * The ID of the entity to which the tags belong. + */ + entityId: number; /** * The tags to display. */ @@ -45,7 +50,7 @@ export interface TagsPanelProps { export const TagsPanel = (props: TagsPanelProps) => { const { classes, cx } = useStyles(); - const { disabled, tags, updateTags } = props; + const { disabled, entityId, tags, updateTags } = props; const queryClient = useQueryClient(); @@ -61,6 +66,12 @@ export const TagsPanel = (props: TagsPanelProps) => { isLoading: userTagsLoading, } = useTagSuggestions(!profile?.restricted); + const isLinodesGrantReadOnly = useIsResourceRestricted({ + grantLevel: 'read_only', + grantType: 'linode', + id: entityId, + }); + const tagsToSuggest = React.useMemo( () => userTags @@ -165,7 +176,7 @@ export const TagsPanel = (props: TagsPanelProps) => { className={classes.selectTag} creatable createOptionPosition="first" - disabled={disabled} + disabled={disabled || isLinodesGrantReadOnly} escapeClearsValue hideLabel isLoading={userTagsLoading} @@ -184,7 +195,7 @@ export const TagsPanel = (props: TagsPanelProps) => { > } onClick={toggleTagInput} > @@ -206,6 +217,7 @@ export const TagsPanel = (props: TagsPanelProps) => { [classes.tag]: true, })} colorVariant="lightBlue" + disabled={disabled || isLinodesGrantReadOnly} key={`tag-item-${thisTag}`} label={thisTag} maxLength={30} diff --git a/packages/manager/src/features/Domains/DomainDetail/DomainDetail.tsx b/packages/manager/src/features/Domains/DomainDetail/DomainDetail.tsx index 14f1ce02ed8..1c86e4716c1 100644 --- a/packages/manager/src/features/Domains/DomainDetail/DomainDetail.tsx +++ b/packages/manager/src/features/Domains/DomainDetail/DomainDetail.tsx @@ -107,7 +107,7 @@ export const DomainDetail = () => { title="Domain Details" /> {location.state && location.state.recordError && ( - + )} @@ -123,7 +123,11 @@ export const DomainDetail = () => { Tags - + { - + diff --git a/packages/manager/src/features/Linodes/LinodeEntityDetail.tsx b/packages/manager/src/features/Linodes/LinodeEntityDetail.tsx index 816faf4c2bc..495caa4e503 100644 --- a/packages/manager/src/features/Linodes/LinodeEntityDetail.tsx +++ b/packages/manager/src/features/Linodes/LinodeEntityDetail.tsx @@ -1,7 +1,10 @@ import * as React from 'react'; import { EntityDetail } from 'src/components/EntityDetail/EntityDetail'; +import { Notice } from 'src/components/Notice/Notice'; +import { getRestrictedResourceText } from 'src/features/Account/utils'; import { notificationContext as _notificationContext } from 'src/features/NotificationCenter/NotificationContext'; +import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; import { useVPCConfigInterface } from 'src/hooks/useVPCConfigInterface'; import { useInProgressEvents } from 'src/queries/events/events'; import { useAllImagesQuery } from 'src/queries/images'; @@ -64,6 +67,12 @@ export const LinodeEntityDetail = (props: Props) => { vpcLinodeIsAssignedTo, } = useVPCConfigInterface(linode.id); + const isLinodesGrantReadOnly = useIsResourceRestricted({ + grantLevel: 'read_only', + grantType: 'linode', + id: linode.id, + }); + const imageVendor = images?.find((i) => i.id === linode.image)?.vendor ?? null; @@ -83,55 +92,67 @@ export const LinodeEntityDetail = (props: Props) => { const trimmedIPv6 = linode.ipv6?.replace('/128', '') || null; return ( - - } - footer={ - - } - header={ - + {isLinodesGrantReadOnly && ( + - } - /> + )} + + } + footer={ + + } + header={ + + } + /> + ); }; diff --git a/packages/manager/src/features/Linodes/LinodeEntityDetailFooter.tsx b/packages/manager/src/features/Linodes/LinodeEntityDetailFooter.tsx index 203d50752ce..4972bd71b73 100644 --- a/packages/manager/src/features/Linodes/LinodeEntityDetailFooter.tsx +++ b/packages/manager/src/features/Linodes/LinodeEntityDetailFooter.tsx @@ -1,5 +1,5 @@ -import Grid from '@mui/material/Unstable_Grid2'; import { useTheme } from '@mui/material/styles'; +import Grid from '@mui/material/Unstable_Grid2'; import { useSnackbar } from 'notistack'; import * as React from 'react'; @@ -34,6 +34,7 @@ export type Props = LinodeEntityDetailProps & { }; interface FooterProps { + isLinodesGrantReadOnly: boolean; linodeCreated: string; linodeId: number; linodeLabel: string; @@ -49,6 +50,7 @@ export const LinodeEntityDetailFooter = React.memo((props: FooterProps) => { const { data: profile } = useProfile(); const { + isLinodesGrantReadOnly, linodeCreated, linodeId, linodePlan, @@ -160,6 +162,7 @@ export const LinodeEntityDetailFooter = React.memo((props: FooterProps) => { flexDirection: 'row-reverse', }, }} + disabled={isLinodesGrantReadOnly} listAllTags={openTagDrawer} tags={linodeTags} updateTags={updateTags} diff --git a/packages/manager/src/features/Linodes/LinodeEntityDetailHeader.tsx b/packages/manager/src/features/Linodes/LinodeEntityDetailHeader.tsx index b792be8fac7..063453886d8 100644 --- a/packages/manager/src/features/Linodes/LinodeEntityDetailHeader.tsx +++ b/packages/manager/src/features/Linodes/LinodeEntityDetailHeader.tsx @@ -16,6 +16,7 @@ import { TypographyProps } from 'src/components/Typography'; import { LinodeActionMenu } from 'src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenu'; import { ProgressDisplay } from 'src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow'; import { lishLaunch } from 'src/features/Lish/lishUtils'; +import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; import { queryKey as linodesQueryKey } from 'src/queries/linodes/linodes'; import { sendLinodeActionMenuItemEvent } from 'src/utilities/analytics'; @@ -82,6 +83,12 @@ export const LinodeEntityDetailHeader = ( variant, } = props; + const isLinodesGrantReadOnly = useIsResourceRestricted({ + grantLevel: 'read_only', + grantType: 'linode', + id: linodeId, + }); + const isRunning = linodeStatus === 'running'; const isOffline = linodeStatus === 'stopped' || linodeStatus === 'offline'; @@ -190,14 +197,14 @@ export const LinodeEntityDetailHeader = ( handlers.onOpenPowerDialog(isRunning ? 'Power Off' : 'Power On') } buttonType="secondary" - disabled={!(isRunning || isOffline)} + disabled={!(isRunning || isOffline) || isLinodesGrantReadOnly} sx={sxActionItem} > {isRunning ? 'Power Off' : 'Power On'}