diff --git a/packages/manager/package.json b/packages/manager/package.json index 676546b1543..bb910a6c8be 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -45,7 +45,9 @@ "@tanstack/react-query-devtools": "5.51.24", "@tanstack/react-router": "^1.111.11", "@xterm/xterm": "^5.5.0", - "akamai-cds-react-components": "0.1.0", + "@akamai/cds-components": "0.0.0-20260407204557", + "@akamai/cds-icons": "0.0.0-20260407204557", + "@akamai/cds-tokens": "0.0.0-20260407204557", "algoliasearch": "^4.14.3", "axios": "~1.13.5", "braintree-web": "^3.92.2", diff --git a/packages/manager/src/components/ImageSelect/ImageSelectTable.tsx b/packages/manager/src/components/ImageSelect/ImageSelectTable.tsx index 46c5456320c..e2296485ece 100644 --- a/packages/manager/src/components/ImageSelect/ImageSelectTable.tsx +++ b/packages/manager/src/components/ImageSelect/ImageSelectTable.tsx @@ -17,7 +17,7 @@ import { useTheme, } from '@linode/ui'; import useMediaQuery from '@mui/material/useMediaQuery'; -import { Pagination } from 'akamai-cds-react-components/Pagination'; +import { Pagination } from '@akamai/cds-components/react/Pagination'; import { Table, TableBody, @@ -25,7 +25,7 @@ import { TableHead, TableHeaderCell, TableRow, -} from 'akamai-cds-react-components/Table'; +} from '@akamai/cds-components/react/Table'; import React, { useState } from 'react'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; diff --git a/packages/manager/src/components/ImageSelect/ImageSelectTableRow.tsx b/packages/manager/src/components/ImageSelect/ImageSelectTableRow.tsx index 98dd251e9a4..199e6dbb2f9 100644 --- a/packages/manager/src/components/ImageSelect/ImageSelectTableRow.tsx +++ b/packages/manager/src/components/ImageSelect/ImageSelectTableRow.tsx @@ -7,7 +7,7 @@ import { } from '@linode/ui'; import { convertStorageUnit, pluralize } from '@linode/utilities'; import useMediaQuery from '@mui/material/useMediaQuery'; -import { TableCell, TableRow } from 'akamai-cds-react-components/Table'; +import { TableCell, TableRow } from '@akamai/cds-components/react/Table'; import React from 'react'; import CloudInitIcon from 'src/assets/icons/cloud-init.svg'; diff --git a/packages/manager/src/features/Account/SwitchAccounts/ChildAccountsTable.tsx b/packages/manager/src/features/Account/SwitchAccounts/ChildAccountsTable.tsx index 524960b2cb0..6ccad3cc06f 100644 --- a/packages/manager/src/features/Account/SwitchAccounts/ChildAccountsTable.tsx +++ b/packages/manager/src/features/Account/SwitchAccounts/ChildAccountsTable.tsx @@ -1,11 +1,11 @@ import { Box, CircleProgress, LinkButton, useTheme } from '@linode/ui'; -import { Pagination } from 'akamai-cds-react-components/Pagination'; +import { Pagination } from '@akamai/cds-components/react/Pagination'; import { Table, TableBody, TableCell, TableRow, -} from 'akamai-cds-react-components/Table'; +} from '@akamai/cds-components/react/Table'; import React from 'react'; import { MIN_PAGE_SIZE } from 'src/components/PaginationFooter/PaginationFooter.constants'; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPoolRow.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPoolRow.tsx index a8fe2fe0c43..39e72ae2392 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPoolRow.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPoolRow.tsx @@ -1,5 +1,5 @@ import { Hidden } from '@linode/ui'; -import { TableCell, TableRow } from 'akamai-cds-react-components/Table'; +import { TableCell, TableRow } from '@akamai/cds-components/react/Table'; import * as React from 'react'; import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.tsx index b6664b2cc9f..4eee774475b 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.tsx @@ -9,7 +9,7 @@ import { } from '@linode/ui'; import Grid from '@mui/material/Grid'; import { useTheme } from '@mui/material/styles'; -import { Pagination } from 'akamai-cds-react-components/Pagination'; +import { Pagination } from '@akamai/cds-components/react/Pagination'; import { Table, TableBody, @@ -17,7 +17,7 @@ import { TableHead, TableHeaderCell, TableRow, -} from 'akamai-cds-react-components/Table'; +} from '@akamai/cds-components/react/Table'; import React from 'react'; import { Link } from 'src/components/Link'; diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingTable.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingTable.tsx index 3eaeba8e5e9..938d366b995 100644 --- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingTable.tsx +++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingTable.tsx @@ -1,13 +1,13 @@ import { Hidden } from '@linode/ui'; import { useTheme } from '@mui/material/styles'; -import { Pagination } from 'akamai-cds-react-components/Pagination'; +import { Pagination } from '@akamai/cds-components/react/Pagination'; import { Table, TableBody, TableHead, TableHeaderCell, TableRow, -} from 'akamai-cds-react-components/Table'; +} from '@akamai/cds-components/react/Table'; import React from 'react'; import { MIN_PAGE_SIZE } from 'src/components/PaginationFooter/PaginationFooter.constants'; diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseRow.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseRow.tsx index decaf48b49a..9734c4219e0 100644 --- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseRow.tsx +++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseRow.tsx @@ -5,7 +5,7 @@ import { } from '@linode/queries'; import { Chip, Hidden } from '@linode/ui'; import { formatStorageUnits } from '@linode/utilities'; -import { TableCell, TableRow } from 'akamai-cds-react-components/Table'; +import { TableCell, TableRow } from '@akamai/cds-components/react/Table'; import * as React from 'react'; import { Link } from 'src/components/Link'; diff --git a/packages/manager/src/features/IAM/Delegations/UpdateDelegationForm.tsx b/packages/manager/src/features/IAM/Delegations/UpdateDelegationForm.tsx index 332487cdf59..bebb6d5b701 100644 --- a/packages/manager/src/features/IAM/Delegations/UpdateDelegationForm.tsx +++ b/packages/manager/src/features/IAM/Delegations/UpdateDelegationForm.tsx @@ -1,27 +1,30 @@ +import { + Button, + Checkbox, + LoadingSpinner, + Table, + TableBody, + TableCell, + TableRow, + TextField, +} from '@akamai/cds-components/react'; import { useAccountUsersInfiniteQuery, useAllAccountUsersQuery, useUpdateChildAccountDelegatesQuery, } from '@linode/queries'; -import { - ActionsPanel, - Autocomplete, - CloseIcon, - IconButton, - Notice, - Paper, - Stack, - Typography, -} from '@linode/ui'; +import { ActionsPanel, Notice, Typography } from '@linode/ui'; import { useDebouncedValue } from '@linode/utilities'; import { useTheme } from '@mui/material'; import { enqueueSnackbar } from 'notistack'; import * as React from 'react'; -import { Controller, FormProvider, useForm } from 'react-hook-form'; +import { FormProvider, useForm } from 'react-hook-form'; import { usePermissions } from '../hooks/usePermissions'; -import { IAM_PARENT_USERS_PENDO_IDS } from '../Shared/constants'; -import { INTERNAL_ERROR_NO_CHANGES_SAVED } from '../Shared/constants'; +import { + IAM_PARENT_USERS_PENDO_IDS, + INTERNAL_ERROR_NO_CHANGES_SAVED, +} from '../Shared/constants'; import { getPlaceholder } from '../Shared/Entities/utils'; import type { @@ -64,8 +67,13 @@ export const UpdateDelegationForm = ({ username: { '+contains': debouncedInputValue }, }; - const { data, error, fetchNextPage, hasNextPage, isFetching } = - useAccountUsersInfiniteQuery(apiFilter); + const { + data, + error: fetchError, + fetchNextPage, + hasNextPage, + isFetching, + } = useAccountUsersInfiniteQuery(apiFilter); const totalUserCount = data?.pages[0]?.results ?? 0; @@ -77,8 +85,6 @@ export const UpdateDelegationForm = ({ user_type: 'parent', }); - const isSelectAllFetching = allUserSelected && isFetchingAllUsers; - const { mutateAsync: updateDelegates } = useUpdateChildAccountDelegatesQuery(); @@ -89,7 +95,6 @@ export const UpdateDelegationForm = ({ }); const { - control, formState: { errors, isSubmitting }, handleSubmit, reset, @@ -120,10 +125,6 @@ export const UpdateDelegationForm = ({ const isSearching = inputValue.length > 0 && debouncedInputValue !== inputValue; - const isLoadingOptions = isFetching || isFetchingAllUsers; - - const showNoOptionsText = !isLoadingOptions && !isSearching; - const onSubmit = async (values: UpdateDelegationsFormValues) => { const usersList = values.users.map((user) => user.value); @@ -158,6 +159,69 @@ export const UpdateDelegationForm = ({ reset(); onClose(); setAllUserSelected(false); + setShowOnly(false); + }; + + const [showOnly, setShowOnly] = React.useState(false); + + const view = React.useMemo((): Array<{ + name: string; + option: UserOption; + rank: number; + }> => { + const source = (showOnly ? selectedUsers : users) as UserOption[]; + return source.map((u, idx) => ({ rank: idx, name: u.label, option: u })); + }, [users, selectedUsers, showOnly]); + + const showNoUsersText = + !isFetching && !isSearching && !fetchError && view.length === 0; + + const selected = React.useMemo(() => { + const map: Record = {}; + view.forEach((p) => { + if (selectedUsers.some((u) => u.value === p.option.value)) { + map[p.rank] = true; + } + }); + return map; + }, [view, selectedUsers]); + + const clearDisabled = !view.some((p) => + selectedUsers.some((u) => u.value === p.option.value) + ); + + const clearVisible = () => { + const visibleValues = new Set(view.map((p) => p.option.value)); + setValue( + 'users', + selectedUsers.filter((u) => !visibleValues.has(u.value)) + ); + }; + + const handleSelectAll = () => { + const allCurrentOptionsSelected = + totalUserCount > 0 && selectedUsers.length >= totalUserCount; + if (allCurrentOptionsSelected) { + setValue('users', []); + setAllUserSelected(false); + } else { + onSelectAllClick(); + } + }; + + const setSel = (rank: number, checked: boolean) => { + const p = view.find((item) => item.rank === rank); + if (!p) return; + if (checked) { + if (!selectedUsers.some((u) => u.value === p.option.value)) { + setValue('users', [...selectedUsers, p.option]); + } + } else { + setValue( + 'users', + selectedUsers.filter((u) => u.value !== p.option.value) + ); + } }; return ( @@ -185,113 +249,153 @@ export const UpdateDelegationForm = ({ Update delegation for {delegation.company}: - ( - - option.value === value.value - } - label="Delegate Users" - loading={isFetching || isFetchingAllUsers} - multiple - noMarginTop - noOptionsText={showNoOptionsText ? 'No users found' : ' '} - onChange={(_, newValue) => { - field.onChange(newValue || []); - }} - onInputChange={(_, value) => { - setInputValue(value); - }} - onSelectAllClick={(_event) => { - const allCurrentOptionsSelected = - totalUserCount > 0 && - selectedUsers.length >= totalUserCount; - if (allCurrentOptionsSelected) { - setValue('users', []); - setAllUserSelected(false); - } else { - onSelectAllClick(); - } - }} - options={users} - renderTags={() => null} - slotProps={{ - listbox: { - onScroll: (event: React.SyntheticEvent) => { - const listboxNode = event.currentTarget; - if ( - listboxNode.scrollTop + listboxNode.clientHeight >= - listboxNode.scrollHeight && - hasNextPage - ) { - fetchNextPage(); - } - }, - }, - }} - textFieldProps={{ - hideLabel: true, - helperText: isSelectAllFetching - ? 'Fetching all users...' - : undefined, - InputProps: isSelectAllFetching - ? { startAdornment: null } - : undefined, - placeholder: getPlaceholder( - 'delegates', - selectedUsers.length, - totalUserCount - ), - }} - value={field.value} - /> - )} - /> - - Users in the account delegation - {isFetchingAllUsers ? '' : ` (${selectedUsers.length})`}: - - ({ - backgroundColor: isFetchingAllUsers - ? theme.tokens.alias.Interaction.Background.Disabled - : theme.palette.background.paper, - maxHeight: 370, - overflowY: 'auto', - p: 2, - py: 1, - })} - variant="outlined" +
- - {selectedUsers.length === 0 && ( - - No users selected - + ) => + setInputValue(String(e.detail ?? '')) + } + placeholder={getPlaceholder( + 'delegates', + selectedUsers.length, + totalUserCount )} - {selectedUsers.map((user) => ( - - setValue( - 'users', - selectedUsers.filter((u) => u.value !== user.value) - ) - } - username={user.label} - /> - ))} - - + value={inputValue} + /> +
+ + Items: {showOnly ? view.length : totalUserCount} | Selected:{' '} + {selectedUsers.length} + + ) => setShowOnly(!!e.detail)} + > + Show only selected + +
+ + +
+
+
{ + if (showOnly) return; + const { scrollTop, scrollHeight, clientHeight } = + e.currentTarget; + if ( + scrollHeight - scrollTop <= clientHeight * 1.5 && + hasNextPage && + !isFetching + ) { + fetchNextPage(); + } + }} + style={{ + maxHeight: '200px', + overflowY: 'auto', + overflowX: 'hidden', + width: '100%', + boxSizing: 'border-box', + }} + > + + + {view.map((p) => ( + { + if (isSubmitting) return; + const t = e.target as Element; + if (t.closest?.('cds-checkbox')) return; + setSel(p.rank, !selected[p.rank]); + }} + rowborder + selected={!!selected[p.rank]} + > + + ) => { + setSel(p.rank, !!e.detail); + }} + onClick={(e: React.MouseEvent) => e.stopPropagation()} + /> + + {p.name} + + + + ))} + {!showOnly && isFetching && ( + + + + + + )} + {showNoUsersText && ( + + + No users found + + + )} + {fetchError && ( + + + {fetchError[0]?.reason ?? 'Failed to load users'} + + + )} + +
+
+
); }; - -interface DelegationUserRowProps { - isSubmitting: boolean; - onRemove: () => void; - username: string; -} - -const DelegationUserRow = ({ - onRemove, - username, - isSubmitting, -}: DelegationUserRowProps) => { - return ( - - {username} - - - - - ); -}; diff --git a/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx b/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx index d16de2aa1a9..301c2b89c65 100644 --- a/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx +++ b/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx @@ -4,7 +4,7 @@ import { useTheme } from '@mui/material'; import Grid from '@mui/material/Grid'; import Paper from '@mui/material/Paper'; import { useLocation, useNavigate, useSearch } from '@tanstack/react-router'; -import { Pagination } from 'akamai-cds-react-components/Pagination'; +import { Pagination } from '@akamai/cds-components/react/Pagination'; import { sortRows, Table, @@ -14,7 +14,7 @@ import { TableHeaderCell, TableRow, TableRowExpanded, -} from 'akamai-cds-react-components/Table'; +} from '@akamai/cds-components/react/Table'; import React, { useState } from 'react'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; @@ -38,7 +38,7 @@ import { import type { RoleView } from '../../Shared/types'; import type { SelectOption } from '@linode/ui'; -import type { Order } from 'akamai-cds-react-components/Table'; +import type { Order } from '@akamai/cds-components/react/Table'; const ALL_ROLES_OPTION: SelectOption = { label: 'All Roles', diff --git a/packages/manager/src/features/IAM/Shared/AssignedPermissionsPanel/AssignedPermissionsPanel.style.ts b/packages/manager/src/features/IAM/Shared/AssignedPermissionsPanel/AssignedPermissionsPanel.style.ts index 1eeecca673e..a424d125f25 100644 --- a/packages/manager/src/features/IAM/Shared/AssignedPermissionsPanel/AssignedPermissionsPanel.style.ts +++ b/packages/manager/src/features/IAM/Shared/AssignedPermissionsPanel/AssignedPermissionsPanel.style.ts @@ -7,6 +7,7 @@ export const StyledPaper = styled(Paper)(({ theme }) => ({ : theme.tokens.color.Neutrals[100], marginTop: theme.tokens.spacing.S8, padding: theme.tokens.spacing.S12, + boxSizing: 'border-box', })); export const StyledTitle = styled(Typography, { diff --git a/packages/manager/src/features/IAM/Shared/Entities/EntitiesSelect.tsx b/packages/manager/src/features/IAM/Shared/Entities/EntitiesSelect.tsx index f2bf5ba4f28..e36d32c88cf 100644 --- a/packages/manager/src/features/IAM/Shared/Entities/EntitiesSelect.tsx +++ b/packages/manager/src/features/IAM/Shared/Entities/EntitiesSelect.tsx @@ -1,12 +1,14 @@ import { - Autocomplete, - CloseIcon, - IconButton, - Notice, - Paper, - Stack, - Typography, -} from '@linode/ui'; + Button, + Checkbox, + LoadingSpinner, + Table, + TableBody, + TableCell, + TableRow, + TextField, +} from '@akamai/cds-components/react'; +import { Notice, Typography } from '@linode/ui'; import { useTheme } from '@mui/material'; import React from 'react'; @@ -45,13 +47,14 @@ export const EntitiesSelect = ({ type, value, }: Props) => { - const { data: entities, isLoading } = useAllAccountEntities({}); + const { + data: entities, + error: fetchError, + isLoading, + } = useAllAccountEntities({}); const theme = useTheme(); - const [displayCount, setDisplayCount] = React.useState(INITIAL_DISPLAY_COUNT); - const [inputValue, setInputValue] = React.useState(''); - - const memoizedEntities = React.useMemo(() => { + const entityOptions = React.useMemo(() => { if (access !== 'entity_access' || !entities) { return []; } @@ -60,29 +63,88 @@ export const EntitiesSelect = ({ return typeEntities ? mapEntitiesToOptions(typeEntities) : []; }, [entities, access, type]); - const filteredEntities = React.useMemo(() => { - if (!inputValue) { - return memoizedEntities; + const [filterText, setFilterText] = React.useState(''); + const [showSelectedOnly, setShowSelectedOnly] = React.useState(false); + const [displayCount, setDisplayCount] = React.useState(INITIAL_DISPLAY_COUNT); + + const filteredRows = React.useMemo(() => { + const filtered = filterText + ? entityOptions.filter((opt) => + opt.label.toLowerCase().includes(filterText.toLowerCase()) + ) + : entityOptions; + const withRank = filtered.map((opt, idx) => ({ + rank: idx, + name: opt.label, + option: opt, + })); + if (showSelectedOnly) { + return withRank.filter((p) => + value.some((v) => v.value === p.option.value) + ); } + return withRank; + }, [entityOptions, filterText, showSelectedOnly, value]); - return memoizedEntities.filter((option) => - option.label.toLowerCase().includes(inputValue.toLowerCase()) + React.useEffect(() => { + setDisplayCount(INITIAL_DISPLAY_COUNT); + }, [filterText, showSelectedOnly]); + + const visibleRows = React.useMemo(() => { + const slice = filteredRows.slice(0, displayCount); + // Always include selected items even if beyond the display slice + const selectedNotVisible = filteredRows.filter( + (p) => + value.some((v) => v.value === p.option.value) && + !slice.some((s) => s.rank === p.rank) ); - }, [memoizedEntities, inputValue]); + return [...slice, ...selectedNotVisible]; + }, [filteredRows, displayCount, value]); - const visibleOptions = React.useMemo(() => { - const slice = filteredEntities.slice(0, displayCount); + const selectionMap = React.useMemo(() => { + const map: Record = {}; + filteredRows.forEach((p) => { + if (value.some((v) => v.value === p.option.value)) { + map[p.rank] = true; + } + }); + return map; + }, [filteredRows, value]); - const selectedNotVisible = value.filter( - (selected) => !slice.some((opt) => opt.value === selected.value) - ); + const selectedCount = value.length; + const clearDisabled = !filteredRows.some((p) => + value.some((v) => v.value === p.option.value) + ); + const selectAllDisabled = filteredRows.every((p) => + value.some((v) => v.value === p.option.value) + ); - return [...slice, ...selectedNotVisible]; - }, [filteredEntities, displayCount, value]); + const handleClear = () => { + const visibleValues = new Set(filteredRows.map((p) => p.option.value)); + onChange(value.filter((v) => !visibleValues.has(v.value))); + }; - React.useEffect(() => { - setDisplayCount(INITIAL_DISPLAY_COUNT); - }, [filteredEntities]); + const handleSelectAll = () => { + const currentValues = new Set(value.map((v) => v.value)); + const toAdd = filteredRows + .filter((p) => !currentValues.has(p.option.value)) + .map((p) => p.option); + onChange([...value, ...toAdd]); + }; + + const toggleEntity = (rank: number, checked: boolean) => { + const p = filteredRows.find((item) => item.rank === rank); + if (!p) return; + if (checked) { + if (!value.some((v) => v.value === p.option.value)) { + onChange([...value, p.option]); + } + } else { + onChange(value.filter((v) => v.value !== p.option.value)); + } + }; + + const isReadOnly = mode === 'change-role'; if (access === 'account_access') { return ( @@ -108,93 +170,158 @@ export const EntitiesSelect = ({ return ( <> - option.label} - isOptionEqualToValue={(option, value) => option.value === value.value} - label="Entities" - loading={isLoading} - multiple - noMarginTop - onChange={(_, newValue, reason) => { - if ( - reason === 'selectOption' && - newValue.length === displayCount && - filteredEntities.length > displayCount - ) { - onChange(filteredEntities); - } else { - onChange(newValue || []); - } - }} - onInputChange={(_, value) => { - setInputValue(value); - }} - options={visibleOptions} - readOnly={mode === 'change-role'} - renderTags={() => null} - slotProps={{ - listbox: { - onScroll: (e) => { - const { scrollTop, scrollHeight, clientHeight } = e.currentTarget; - if (scrollHeight - scrollTop <= clientHeight * 1.5) { - setDisplayCount((prev) => - Math.min(prev + 200, filteredEntities.length) - ); - } - }, - }, - }} - textFieldProps={{ - placeholder: getPlaceholder( - type, - value.length, - filteredEntities.length - ), + {errorText && ( + + {errorText} + + )} +
- {memoizedEntities.length > 0 && !isLoading && ( - <> - - Selected entities ({value.length}): - - ({ - backgroundColor: isLoading - ? theme.tokens.alias.Interaction.Background.Disabled - : theme.palette.background.paper, - maxHeight: 370, - overflowY: 'auto', - p: 2, - py: 1, - })} - variant="outlined" + > +

+ Entities +

+ ) => + setFilterText(String(e.detail ?? '')) + } + placeholder={getPlaceholder(type, value.length, entityOptions.length)} + value={filterText} + /> + {entityOptions.length > 0 && ( +
- - {value.length === 0 && ( - - No entities selected - - )} - {value.map((entity) => ( - - onChange(value.filter((v) => v.value !== entity.value)) - } - /> + + Items: {filteredRows.length} | Selected: {selectedCount} + + ) => + setShowSelectedOnly(!!e.detail) + } + > + Show only selected + +
+ + +
+
+ )} +
{ + const { scrollTop, scrollHeight, clientHeight } = e.currentTarget; + if (scrollHeight - scrollTop <= clientHeight * 1.5) { + setDisplayCount((prev) => + Math.min(prev + 200, filteredRows.length) + ); + } + }} + style={{ + maxHeight: '200px', + overflowY: 'auto', + overflowX: 'hidden', + width: '100%', + boxSizing: 'border-box', + }} + > + + + {visibleRows.map((p) => ( + { + if (isReadOnly) return; + const t = e.target as Element; + if (t.closest?.('cds-menu') || t.closest?.('cds-checkbox')) + return; + toggleEntity(p.rank, !selectionMap[p.rank]); + }} + rowborder + selected={!!selectionMap[p.rank]} + > + + ) => { + toggleEntity(p.rank, !!e.detail); + }} + onClick={(e: React.MouseEvent) => e.stopPropagation()} + /> + + {p.name} + + + ))} - - - - )} - {!memoizedEntities.length && !isLoading && ( - + {isLoading && ( + + + + + + )} + {!isLoading && !fetchError && visibleRows.length === 0 && entityOptions.length > 0 && ( + + + No entities found + + + )} + {fetchError && ( + + + {(fetchError as { reason?: string })?.reason ?? + 'Failed to load entities'} + + + )} + +
+
+
+ + {!entityOptions.length && !isLoading && ( + Create {type === 'image' ? `an` : `a`}{' '} @@ -207,26 +334,3 @@ export const EntitiesSelect = ({ ); }; - -interface EntityRowProps { - disabled?: boolean; - label: string; - onRemove: () => void; -} - -const EntityRow = ({ disabled, label, onRemove }: EntityRowProps) => { - return ( - - {label} - {!disabled && ( - - - - )} - - ); -}; diff --git a/packages/manager/src/features/IAM/Shared/ErrorState/ErrorState.test.tsx b/packages/manager/src/features/IAM/Shared/ErrorState/ErrorState.test.tsx new file mode 100644 index 00000000000..67dafc318e9 --- /dev/null +++ b/packages/manager/src/features/IAM/Shared/ErrorState/ErrorState.test.tsx @@ -0,0 +1,21 @@ +import { screen } from '@testing-library/react'; +import React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { ERROR_STATE_TEXT_1, ERROR_STATE_TITLE } from '../constants'; +import { ErrorState } from './ErrorState'; + +describe('ErrorState', () => { + it('renders with default error text', async () => { + renderWithTheme(); + expect(screen.getByText(ERROR_STATE_TITLE)).toBeVisible(); + expect(screen.getByText(ERROR_STATE_TEXT_1)).toBeVisible(); + }); + + it('renders with custom error text', async () => { + const customErrorText = 'Custom error message'; + renderWithTheme(); + expect(screen.getByText(customErrorText)).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/IAM/Shared/ErrorState/ErrorState.tsx b/packages/manager/src/features/IAM/Shared/ErrorState/ErrorState.tsx new file mode 100644 index 00000000000..1d8a3438c09 --- /dev/null +++ b/packages/manager/src/features/IAM/Shared/ErrorState/ErrorState.tsx @@ -0,0 +1,30 @@ +import { + ZeroErrorDescription, + ZeroErrorIcon, + ZeroErrorState, + ZeroErrorTitle, +} from '@akamai/cds-components/react'; +import React from 'react'; + +import { ERROR_STATE_TEXT_1, ERROR_STATE_TITLE } from '../constants'; + +interface Props { + errorText?: string; +} +export const ErrorState = (props: Props) => { + const { errorText } = props; + + return ( + + + {errorText ? ( + {errorText} + ) : ( + <> + {ERROR_STATE_TITLE} + {ERROR_STATE_TEXT_1} + + )} + + ); +}; diff --git a/packages/manager/src/features/IAM/Shared/NoAssignedRoles/NoAssignedRoles.tsx b/packages/manager/src/features/IAM/Shared/NoAssignedRoles/NoAssignedRoles.tsx index 285e87bd76d..892b23c4ff4 100644 --- a/packages/manager/src/features/IAM/Shared/NoAssignedRoles/NoAssignedRoles.tsx +++ b/packages/manager/src/features/IAM/Shared/NoAssignedRoles/NoAssignedRoles.tsx @@ -1,13 +1,17 @@ -import { Box, Button, Typography, useTheme } from '@linode/ui'; +import { + ZeroErrorActions, + ZeroErrorDescription, + ZeroErrorIcon, + ZeroErrorState, + ZeroErrorTitle, +} from '@akamai/cds-components/react'; +import { Button } from '@linode/ui'; import React from 'react'; -import EmptyState from 'src/assets/icons/empty-state-cloud.svg'; - import { useIsDefaultDelegationRolesForChildAccount } from '../../hooks/useDelegationRole'; import { usePermissions } from '../../hooks/usePermissions'; import { AssignNewRoleDrawer } from '../../Users/UserRoles/AssignNewRoleDrawer'; import { IAM_ROLES_PENDO_IDS } from '../constants'; - interface Props { hasAssignNewRoleDrawer: boolean; text: string; @@ -15,7 +19,6 @@ interface Props { export const NoAssignedRoles = (props: Props) => { const { text, hasAssignNewRoleDrawer } = props; - const theme = useTheme(); const { data: permissions } = usePermissions('account', [ 'is_account_admin', 'update_default_delegate_access', @@ -31,52 +34,37 @@ export const NoAssignedRoles = (props: Props) => { React.useState(false); return ( - - - This list is empty - - {text} - - {hasAssignNewRoleDrawer && ( - - )} + + + This list is empty + {text} + + {hasAssignNewRoleDrawer && ( + + )} + setIsAssignNewRoleDrawerOpen(false)} open={isAssignNewRoleDrawerOpen} /> - + ); }; diff --git a/packages/manager/src/features/IAM/Shared/NotFound/NotFound.test.tsx b/packages/manager/src/features/IAM/Shared/NotFound/NotFound.test.tsx new file mode 100644 index 00000000000..99a7fc320ab --- /dev/null +++ b/packages/manager/src/features/IAM/Shared/NotFound/NotFound.test.tsx @@ -0,0 +1,14 @@ +import { screen } from '@testing-library/react'; +import React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { NotFound } from './NotFound'; + +describe('NotFound', () => { + it('renders with default error text', async () => { + renderWithTheme(); + expect(screen.getByText('Not Found')).toBeVisible(); + expect(screen.getByText('This page does not exist.')).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/IAM/Shared/NotFound/NotFound.tsx b/packages/manager/src/features/IAM/Shared/NotFound/NotFound.tsx new file mode 100644 index 00000000000..66ad2dc247e --- /dev/null +++ b/packages/manager/src/features/IAM/Shared/NotFound/NotFound.tsx @@ -0,0 +1,17 @@ +import { + ZeroErrorDescription, + ZeroErrorIcon, + ZeroErrorState, + ZeroErrorTitle, +} from '@akamai/cds-components/react'; +import React from 'react'; + +export const NotFound = () => { + return ( + + + Not Found + This page does not exist. + + ); +}; diff --git a/packages/manager/src/features/IAM/Shared/constants.ts b/packages/manager/src/features/IAM/Shared/constants.ts index c91e73df2d1..73eae2927fb 100644 --- a/packages/manager/src/features/IAM/Shared/constants.ts +++ b/packages/manager/src/features/IAM/Shared/constants.ts @@ -73,6 +73,10 @@ export const LAST_ACCOUNT_ADMIN_ERROR = export const ERROR_STATE_TEXT = 'An unexpected error occurred. Refresh the page or try again later.'; +export const ERROR_STATE_TITLE = 'An unexpected error occurred.'; + +export const ERROR_STATE_TEXT_1 = 'Refresh the page or try again later.'; + // Delegation error messages export const NO_ITEMS_TO_DISPLAY_TEXT = 'No items to display.'; export const NO_DELEGATED_USERS_TEXT = 'No users added.'; diff --git a/packages/manager/src/features/IAM/Users/UserDetails/UserProfile.test.tsx b/packages/manager/src/features/IAM/Users/UserDetails/UserProfile.test.tsx new file mode 100644 index 00000000000..2898106b94e --- /dev/null +++ b/packages/manager/src/features/IAM/Users/UserDetails/UserProfile.test.tsx @@ -0,0 +1,147 @@ +import { screen } from '@testing-library/react'; +import React from 'react'; + +import { accountUserFactory } from 'src/factories/accountUsers'; +import { userRolesFactory } from 'src/factories/userRoles'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { UserProfile } from './UserProfile'; + +const queryMocks = vi.hoisted(() => ({ + useAccountUser: vi.fn().mockReturnValue({}), + useParams: vi.fn().mockReturnValue({}), + usePermissions: vi.fn().mockReturnValue({}), + useUserRoles: vi.fn().mockReturnValue({}), +})); + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useAccountUser: queryMocks.useAccountUser, + useUserRoles: queryMocks.useUserRoles, + }; +}); + +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useParams: queryMocks.useParams, + }; +}); + +vi.mock('../../hooks/usePermissions', async () => { + const actual = await vi.importActual('../../hooks/usePermissions'); + return { + ...actual, + usePermissions: queryMocks.usePermissions, + }; +}); + +describe('UserProfile', () => { + beforeEach(() => { + vi.clearAllMocks(); + + queryMocks.useParams.mockReturnValue({ username: 'test-user' }); + queryMocks.usePermissions.mockReturnValue({ + data: { + delete_user: true, + list_user_permissions: true, + update_user: true, + view_user: true, + }, + isLoading: false, + }); + queryMocks.useAccountUser.mockReturnValue({ + data: accountUserFactory.build({ + email: 'test-user@example.com', + username: 'test-user', + }), + error: null, + isLoading: false, + }); + queryMocks.useUserRoles.mockReturnValue({ + data: userRolesFactory.build({ + account_access: ['account_admin'], + entity_access: [], + }), + }); + }); + + it('renders a loading state while the user is loading', () => { + queryMocks.useAccountUser.mockReturnValue({ + data: null, + error: null, + isLoading: true, + }); + + renderWithTheme(); + + expect(screen.getByTestId('circle-progress')).toBeVisible(); + }); + + it('shows a permission notice when the user cannot view user details', () => { + queryMocks.usePermissions.mockReturnValue({ + data: { + delete_user: true, + list_user_permissions: false, + update_user: true, + view_user: false, + }, + isLoading: false, + }); + + renderWithTheme(); + + expect( + screen.getByText( + "You do not have permission to view this user's details." + ) + ).toBeVisible(); + }); + + it('shows an error state when loading the user fails', () => { + queryMocks.useAccountUser.mockReturnValue({ + data: null, + error: [{ reason: 'Unable to load user profile.' }], + isLoading: false, + }); + + renderWithTheme(); + + expect(screen.getByText('Unable to load user profile.')).toBeVisible(); + }); + + it('shows a not found state when the user does not exist', () => { + queryMocks.useAccountUser.mockReturnValue({ + data: null, + error: null, + isLoading: false, + }); + + renderWithTheme(); + + expect(screen.getByText('Not Found')).toBeVisible(); + expect(screen.getByText('This page does not exist.')).toBeVisible(); + }); + + it('renders the profile panels with the resolved user data and permissions', () => { + renderWithTheme(); + + expect(queryMocks.usePermissions).toHaveBeenCalledWith('account', [ + 'view_user', + 'update_user', + 'delete_user', + 'list_user_permissions', + ]); + expect(queryMocks.useAccountUser).toHaveBeenCalledWith('test-user', true); + expect(queryMocks.useUserRoles).toHaveBeenCalledWith('test-user', true); + + expect(screen.getByText('test-user')).toBeVisible(); + + expect(screen.getByLabelText('Email')).toHaveDisplayValue( + 'test-user@example.com' + ); + }); +}); diff --git a/packages/manager/src/features/IAM/Users/UserDetails/UserProfile.tsx b/packages/manager/src/features/IAM/Users/UserDetails/UserProfile.tsx index 53b843d9933..e99afc8de4a 100644 --- a/packages/manager/src/features/IAM/Users/UserDetails/UserProfile.tsx +++ b/packages/manager/src/features/IAM/Users/UserDetails/UserProfile.tsx @@ -1,8 +1,8 @@ import { useAccountUser, useUserRoles } from '@linode/queries'; import { CircleProgress, - ErrorState, - NotFound, + // ErrorState, + // NotFound, Notice, Stack, } from '@linode/ui'; @@ -10,6 +10,8 @@ import { useParams } from '@tanstack/react-router'; import React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { ErrorState } from 'src/features/IAM/Shared/ErrorState/ErrorState'; +import { NotFound } from 'src/features/IAM/Shared/NotFound/NotFound'; import { usePermissions } from '../../hooks/usePermissions'; import { DeleteUserPanel } from './DeleteUserPanel'; diff --git a/packages/manager/src/features/IAM/Users/UserRoles/AssignNewRoleDrawer.tsx b/packages/manager/src/features/IAM/Users/UserRoles/AssignNewRoleDrawer.tsx index 5c1bd80fb91..17f9fe4f56d 100644 --- a/packages/manager/src/features/IAM/Users/UserRoles/AssignNewRoleDrawer.tsx +++ b/packages/manager/src/features/IAM/Users/UserRoles/AssignNewRoleDrawer.tsx @@ -1,3 +1,8 @@ +import { + Button, + Drawer, + NotificationBanner, +} from '@akamai/cds-components/react'; import { delegationQueries, iamQueries, @@ -6,22 +11,12 @@ import { useUpdateDefaultDelegationAccessQuery, useUserRolesMutation, } from '@linode/queries'; -import { - ActionsPanel, - Drawer, - LinkButton, - Notice, - Typography, -} from '@linode/ui'; import { useTheme } from '@mui/material'; -import Grid from '@mui/material/Grid'; import { useParams } from '@tanstack/react-router'; import { enqueueSnackbar } from 'notistack'; import React, { useEffect, useState } from 'react'; import { FormProvider, useFieldArray, useForm } from 'react-hook-form'; -import { Link } from 'src/components/Link'; -import { StyledLinkButtonBox } from 'src/components/SelectFirewallPanel/SelectFirewallPanel'; import { AssignSingleRole } from 'src/features/IAM/Users/UserRoles/AssignSingleRole'; import { useIsDefaultDelegationRolesForChildAccount } from '../../hooks/useDelegationRole'; @@ -131,8 +126,8 @@ export const AssignNewRoleDrawer = ({ } enqueueSnackbar(`Roles added.`, { variant: 'success' }); handleClose(); - } catch (error) { - setError(error.field ?? 'root', { + } catch { + setError('root', { message: INTERNAL_ERROR_NO_CHANGES_SAVED, }); } @@ -152,50 +147,51 @@ export const AssignNewRoleDrawer = ({ }, [open, reset]); return ( - + + {isDefaultDelegationRolesForChildAccount ? 'Add New Default Roles' - : 'Assign New Roles' - } - > + : 'Assign New Roles'} + +
{formState.errors.root?.message && ( - + )} - - +

{isDefaultDelegationRolesForChildAccount ? 'Add a role you want to assign by default to new delegate users. Some roles require selecting entities they should apply to. Configure the first role and continue adding roles or save the assignment.' : 'Select a role you want to assign to a user. Some roles require selecting entities they should apply to. Configure the first role and continue adding roles or save the assignment.'}{' '} - + Learn more about roles and permissions - + . - - ({ +

+
- Roles +

+ Roles +

{roles.length > 0 && roles.some((field) => field.role) && ( - - setAreDetailsHidden(!areDetailsHidden)} - > - {areDetailsHidden ? 'Show' : 'Hide'} details - - + )} - +
{!!accountRoles && fields.map((field, index) => ( @@ -211,31 +207,50 @@ export const AssignNewRoleDrawer = ({ {/* If all roles are filled, allow them to add another */} {roles.length > 0 && roles.every((field) => field.role?.value) && ( - - append({ role: null })}> - Add another role - - + )} - + {/* */} + + +
diff --git a/packages/manager/src/features/IAM/Users/UserRoles/AssignSingleRole.tsx b/packages/manager/src/features/IAM/Users/UserRoles/AssignSingleRole.tsx index dd842920440..9e1a6863c27 100644 --- a/packages/manager/src/features/IAM/Users/UserRoles/AssignSingleRole.tsx +++ b/packages/manager/src/features/IAM/Users/UserRoles/AssignSingleRole.tsx @@ -1,6 +1,6 @@ -import { Autocomplete, Button, DeleteIcon } from '@linode/ui'; +import { Button, Icon } from '@akamai/cds-components/react'; +import { Autocomplete } from '@linode/ui'; import { useTheme } from '@mui/material'; -import Box from '@mui/material/Box'; import React from 'react'; import { Controller, useFormContext } from 'react-hook-form'; @@ -38,8 +38,10 @@ export const AssignSingleRole = ({ const roles = watch('roles'); return ( - - +
+
{index !== 0 && ( ( { @@ -63,6 +66,23 @@ export const AssignSingleRole = ({ textFieldProps={{ hideLabel: true }} value={value || null} /> + + // TODO: UIE-10739 - Replace with CDS Select + //