From 9582ca1021e809674531c51066a9f768bf8c5162 Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Fri, 19 Jan 2024 09:09:52 -0500 Subject: [PATCH 01/44] upcoming: [M3-7609] - Placement Groups Landing Page (#10068) * Initial commit - menu entry, routes and skeletons * Saving progress: table rows * Saving progress: Table data * Tests and cleanup * moar cleanup * Compliance col remove, styling and cleanup * Cleanup and changeset * Fix unit test * Update icon and limits * Feedback * Moar Feedback --- packages/api-v4/src/placement-groups/types.ts | 3 +- ...r-10068-upcoming-features-1705596672088.md | 5 + packages/manager/src/MainContent.tsx | 9 + .../icons/entityIcons/placement-groups.svg | 5 + .../PrimaryNav/PrimaryNav.styles.ts | 5 + .../src/components/PrimaryNav/PrimaryNav.tsx | 11 + .../manager/src/factories/placementGroups.ts | 17 +- packages/manager/src/featureFlags.ts | 1 + .../PlacementGroupsLanding.test.tsx | 75 +++++++ .../PlacementGroupsLanding.tsx | 192 ++++++++++++++++++ .../PlacementGroupsRow.styles.ts | 13 ++ .../PlacementGroupsRow.test.tsx | 95 +++++++++ .../PlacementGroupsRow.tsx | 110 ++++++++++ .../src/features/PlacementGroups/constants.ts | 1 + .../src/features/PlacementGroups/index.tsx | 44 ++++ 15 files changed, 580 insertions(+), 6 deletions(-) create mode 100644 packages/manager/.changeset/pr-10068-upcoming-features-1705596672088.md create mode 100644 packages/manager/src/assets/icons/entityIcons/placement-groups.svg create mode 100644 packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.test.tsx create mode 100644 packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.tsx create mode 100644 packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsRow.styles.ts create mode 100644 packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsRow.test.tsx create mode 100644 packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsRow.tsx create mode 100644 packages/manager/src/features/PlacementGroups/constants.ts create mode 100644 packages/manager/src/features/PlacementGroups/index.tsx diff --git a/packages/api-v4/src/placement-groups/types.ts b/packages/api-v4/src/placement-groups/types.ts index 7dc96bfcfa1..ced0f1fe430 100644 --- a/packages/api-v4/src/placement-groups/types.ts +++ b/packages/api-v4/src/placement-groups/types.ts @@ -1,6 +1,6 @@ import type { Region } from '../regions/types'; -export type AffinityType = 'affinity' | 'anti-affinity'; +export type AffinityType = 'affinity' | 'anti_affinity'; export interface PlacementGroup { id: number; @@ -9,6 +9,7 @@ export interface PlacementGroup { affinity_type: AffinityType; compliant: boolean; linode_ids: number[]; + limits: number; } export type CreatePlacementGroupPayload = Pick< diff --git a/packages/manager/.changeset/pr-10068-upcoming-features-1705596672088.md b/packages/manager/.changeset/pr-10068-upcoming-features-1705596672088.md new file mode 100644 index 00000000000..f874e3cdbbc --- /dev/null +++ b/packages/manager/.changeset/pr-10068-upcoming-features-1705596672088.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Placement Groups Landing Page ([#10068](https://github.com/linode/manager/pull/10068)) diff --git a/packages/manager/src/MainContent.tsx b/packages/manager/src/MainContent.tsx index c0594e97b78..5e4e80003ff 100644 --- a/packages/manager/src/MainContent.tsx +++ b/packages/manager/src/MainContent.tsx @@ -169,6 +169,11 @@ const Firewalls = React.lazy(() => import('src/features/Firewalls')); const Databases = React.lazy(() => import('src/features/Databases')); const BetaRoutes = React.lazy(() => import('src/features/Betas')); const VPC = React.lazy(() => import('src/features/VPCs')); +const PlacementGroups = React.lazy(() => + import('src/features/PlacementGroups').then((module) => ({ + default: module.PlacementGroups, + })) +); export const MainContent = () => { const { classes, cx } = useStyles(); @@ -325,6 +330,10 @@ export const MainContent = () => { }> + {flags.aglb && ( diff --git a/packages/manager/src/assets/icons/entityIcons/placement-groups.svg b/packages/manager/src/assets/icons/entityIcons/placement-groups.svg new file mode 100644 index 00000000000..044e800bfb5 --- /dev/null +++ b/packages/manager/src/assets/icons/entityIcons/placement-groups.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.styles.ts b/packages/manager/src/components/PrimaryNav/PrimaryNav.styles.ts index c5aaac9ca31..f37b8be6330 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.styles.ts +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.styles.ts @@ -21,6 +21,11 @@ const useStyles = makeStyles()( left: 70, position: 'absolute', }, + '&.beta-chip-placement-groups': { + bottom: -2, + left: 52, + position: 'absolute', + }, marginTop: 2, }, divider: { diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx index 354c427f634..8061c923d5a 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx @@ -14,6 +14,7 @@ import Linode from 'src/assets/icons/entityIcons/linode.svg'; import Managed from 'src/assets/icons/entityIcons/managed.svg'; import NodeBalancer from 'src/assets/icons/entityIcons/nodebalancer.svg'; import OCA from 'src/assets/icons/entityIcons/oneclick.svg'; +import PlacementGroups from 'src/assets/icons/entityIcons/placement-groups.svg'; import StackScript from 'src/assets/icons/entityIcons/stackscript.svg'; import Volume from 'src/assets/icons/entityIcons/volume.svg'; import VPC from 'src/assets/icons/entityIcons/vpc.svg'; @@ -54,6 +55,7 @@ type NavEntity = | 'Marketplace' | 'NodeBalancers' | 'Object Storage' + | 'Placement Groups' | 'StackScripts' | 'VPC' | 'Volumes'; @@ -171,6 +173,14 @@ export const PrimaryNav = (props: PrimaryNavProps) => { href: '/linodes', icon: , }, + { + betaChipClassName: 'beta-chip-placement-groups', + display: 'Placement Groups', + hide: !flags.vmPlacement, + href: '/placement-groups', + icon: , + isBeta: true, + }, { display: 'Volumes', href: '/volumes', @@ -288,6 +298,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { allowMarketplacePrefetch, flags.databaseBeta, flags.aglb, + flags.vmPlacement, showVPCs, ] ); diff --git a/packages/manager/src/factories/placementGroups.ts b/packages/manager/src/factories/placementGroups.ts index 0bba16d0a33..39805bd8246 100644 --- a/packages/manager/src/factories/placementGroups.ts +++ b/packages/manager/src/factories/placementGroups.ts @@ -8,17 +8,24 @@ import type { } from '@linode/api-v4'; export const placementGroupFactory = Factory.Sync.makeFactory({ - affinity_type: 'anti-affinity', - compliant: true, + affinity_type: Factory.each(() => pickRandom(['affinity', 'anti_affinity'])), + compliant: Factory.each(() => pickRandom([true, false])), id: Factory.each((id) => id), label: Factory.each((id) => `pg-${id}`), - linode_ids: [1, 2, 3], - region: pickRandom(['us-east', 'us-southeast', 'ca-central']), + limits: 10, + linode_ids: Factory.each(() => [ + pickRandom([1, 2, 3]), + pickRandom([4, 5, 6]), + pickRandom([7, 8, 9]), + ]), + region: Factory.each(() => + pickRandom(['us-east', 'us-southeast', 'ca-central']) + ), }); export const createPlacementGroupPayloadFactory = Factory.Sync.makeFactory( { - affinity_type: 'anti-affinity', + affinity_type: 'anti_affinity', label: Factory.each((id) => `mock-pg-${id}`), region: pickRandom(['us-east', 'us-southeast', 'ca-central']), } diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index dfc360f9bf0..70a6b1ea4cf 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -143,6 +143,7 @@ export type ProductInformationBannerLocation = | 'Managed' | 'NodeBalancers' | 'Object Storage' + | 'Placement Groups' | 'StackScripts' | 'VPC' | 'Volumes'; diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.test.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.test.tsx new file mode 100644 index 00000000000..c8c69eb7ec5 --- /dev/null +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.test.tsx @@ -0,0 +1,75 @@ +import * as React from 'react'; + +import { placementGroupFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { PlacementGroupsLanding } from './PlacementGroupsLanding'; + +const queryMocks = vi.hoisted(() => ({ + usePlacementGroupsQuery: vi.fn().mockReturnValue({}), +})); + +vi.mock('src/queries/placementGroups', async () => { + const actual = await vi.importActual('src/queries/placementGroups'); + return { + ...actual, + usePlacementGroupsQuery: queryMocks.usePlacementGroupsQuery, + }; +}); + +describe('PlacementGroupsLanding', () => { + it('renders loading state', () => { + queryMocks.usePlacementGroupsQuery.mockReturnValue({ + isLoading: true, + }); + + const { getByRole } = renderWithTheme(); + + expect(getByRole('progressbar')).toBeInTheDocument(); + }); + + it('renders error state', () => { + queryMocks.usePlacementGroupsQuery.mockReturnValue({ + error: [{ reason: 'Not found' }], + }); + + const { getByText } = renderWithTheme(); + + expect(getByText(/not found/i)).toBeInTheDocument(); + }); + + it('renders docs link and create button', () => { + queryMocks.usePlacementGroupsQuery.mockReturnValue({ + data: { + data: [], + results: 0, + }, + }); + + const { getByText } = renderWithTheme(); + + expect(getByText(/create placement group/i)).toBeInTheDocument(); + expect(getByText(/docs/i)).toBeInTheDocument(); + }); + + it('renders placement groups', () => { + queryMocks.usePlacementGroupsQuery.mockReturnValue({ + data: { + data: [ + placementGroupFactory.build({ + label: 'group 1', + }), + placementGroupFactory.build({ + label: 'group 2', + }), + ], + results: 2, + }, + }); + + const { getByText } = renderWithTheme(); + + expect(getByText(/group 1/i)).toBeInTheDocument(); + expect(getByText(/group 2/i)).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.tsx new file mode 100644 index 00000000000..fa3a6d542dd --- /dev/null +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.tsx @@ -0,0 +1,192 @@ +import CloseIcon from '@mui/icons-material/Close'; +import * as React from 'react'; +import { useHistory } from 'react-router-dom'; + +import { CircleProgress } from 'src/components/CircleProgress'; +import { ErrorState } from 'src/components/ErrorState/ErrorState'; +import { Hidden } from 'src/components/Hidden'; +import { IconButton } from 'src/components/IconButton'; +import { InputAdornment } from 'src/components/InputAdornment'; +import { LandingHeader } from 'src/components/LandingHeader'; +import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; +import { Table } from 'src/components/Table'; +import { TableBody } from 'src/components/TableBody'; +import { TableCell } from 'src/components/TableCell'; +import { TableHead } from 'src/components/TableHead'; +import { TableRow } from 'src/components/TableRow'; +import { TableSortCell } from 'src/components/TableSortCell/TableSortCell'; +import { TextField } from 'src/components/TextField'; +import { Typography } from 'src/components/Typography'; +import { useOrder } from 'src/hooks/useOrder'; +import { usePagination } from 'src/hooks/usePagination'; +import { usePlacementGroupsQuery } from 'src/queries/placementGroups'; +import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; + +import { MAX_NUMBER_OF_PLACEMENT_GROUPS } from '../constants'; +import { PlacementGroupsRow } from './PlacementGroupsRow'; + +import type { PlacementGroup } from '@linode/api-v4'; + +const preferenceKey = 'placement-groups'; + +export const PlacementGroupsLanding = React.memo(() => { + const history = useHistory(); + const pagination = usePagination(1, preferenceKey); + const [_selectedPlacementGroup, setSelectedPlacementGroup] = React.useState< + PlacementGroup | undefined + >(); + const [query, setQuery] = React.useState(''); + const { handleOrderChange, order, orderBy } = useOrder( + { + order: 'asc', + orderBy: 'label', + }, + `${preferenceKey}-order` + ); + + const filter = { + ['+order']: order, + ['+order_by']: orderBy, + }; + + if (query) { + filter['label'] = { '+contains': query }; + } + + const params = { + page: pagination.page, + page_size: pagination.pageSize, + }; + + const { data: placementGroups, error, isLoading } = usePlacementGroupsQuery( + params, + filter + ); + + if (isLoading) { + return ; + } + + // if (placementGroups?.results === 0) { + // return { + // /* TODO VM_Placement: add */ + // }; + // } + + if (error) { + return ( + + ); + } + + const onOpenCreateDrawer = () => { + history.push('/placement-groups/create'); + }; + + const handleRenamePlacementGroup = (placementGroup: PlacementGroup) => { + setSelectedPlacementGroup(placementGroup); + }; + + const handleDeletePlacementGroup = (placementGroup: PlacementGroup) => { + setSelectedPlacementGroup(placementGroup); + }; + + return ( + <> + = MAX_NUMBER_OF_PLACEMENT_GROUPS) || + false, + }} + breadcrumbProps={{ pathname: '/placement-groups' }} + docsLink={'TODO VM_Placement: add doc link'} + entity="Placement Group" + onButtonClick={onOpenCreateDrawer} + title="Placement Groups" + /> + + The maximum amount of Placement Groups is{' '} + {MAX_NUMBER_OF_PLACEMENT_GROUPS} per account. + + + setQuery('')} + size="small" + sx={{ padding: 'unset' }} + > + + + + ), + }} + hideLabel + label="Filter" + onChange={(e) => setQuery(e.target.value)} + placeholder="Filter" + sx={{ mb: 4 }} + value={query} + /> + + + + + Label + + Linodes + + + Region + + + + + + + {placementGroups?.data.map((placementGroup) => ( + + handleDeletePlacementGroup(placementGroup) + } + handleRenamePlacementGroup={() => + handleRenamePlacementGroup(placementGroup) + } + key={`pg-${placementGroup.id}`} + placementGroup={placementGroup} + /> + ))} + +
+ + {/* TODO VM_Placement: add delete dialog */} + {/* TODO VM_Placement: add create/edit drawer */} + + ); +}); diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsRow.styles.ts b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsRow.styles.ts new file mode 100644 index 00000000000..af117412178 --- /dev/null +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsRow.styles.ts @@ -0,0 +1,13 @@ +import Warning from '@mui/icons-material/Warning'; +import { styled } from '@mui/material/styles'; + +export const StyledWarningIcon = styled(Warning, { + label: 'StyledWarningIcon', +})(({ theme }) => ({ + fill: theme.color.yellow, + height: 16, + marginRight: theme.spacing(), + position: 'relative', + top: 2, + width: 16, +})); diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsRow.test.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsRow.test.tsx new file mode 100644 index 00000000000..9c88e3cf6af --- /dev/null +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsRow.test.tsx @@ -0,0 +1,95 @@ +import * as React from 'react'; + +import { linodeFactory } from 'src/factories'; +import { regionFactory } from 'src/factories'; +import { placementGroupFactory } from 'src/factories'; +import { + renderWithTheme, + resizeScreenSize, + wrapWithTableBody, +} from 'src/utilities/testHelpers'; + +import { PlacementGroupsRow } from './PlacementGroupsRow'; + +const queryMocks = vi.hoisted(() => ({ + useLinodesQuery: vi.fn().mockReturnValue({}), + useRegionsQuery: vi.fn().mockReturnValue({}), +})); + +vi.mock('src/queries/linodes/linodes', async () => { + const actual = await vi.importActual('src/queries/linodes/linodes'); + + return { + ...actual, + useLinodesQuery: queryMocks.useLinodesQuery, + }; +}); + +vi.mock('src/queries/regions', async () => { + const actual = await vi.importActual('src/queries/regions'); + + return { + ...actual, + useRegionsQuery: queryMocks.useRegionsQuery, + }; +}); + +const handleDeletePlacementGroupMock = vi.fn(); +const handleRenamePlacementGroupMock = vi.fn(); + +describe('PlacementGroupsLanding', () => { + it('renders the columns with proper data', () => { + resizeScreenSize(1200); + + queryMocks.useLinodesQuery.mockReturnValue({ + data: { + data: [ + linodeFactory.build({ + id: 1, + label: 'linode1', + region: 'us-east', + }), + ], + results: 0, + }, + }); + + queryMocks.useRegionsQuery.mockReturnValue({ + data: [ + regionFactory.build({ + country: 'us', + id: 'us-east', + label: 'Newark, NJ', + status: 'ok', + }), + ], + }); + + const { getByRole, getByTestId, getByText } = renderWithTheme( + wrapWithTableBody( + + ) + ); + + expect(getByTestId('link-to-placement-group-1')).toHaveTextContent( + 'group 1 (Anti-affinity)' + ); + expect(getByText('Non-compliant')).toBeInTheDocument(); + expect(getByTestId('placement-group-1-assigned-linodes')).toHaveTextContent( + '1' + ); + expect(getByText('Newark, NJ')).toBeInTheDocument(); + expect(getByRole('button', { name: 'Rename' })).toBeInTheDocument(); + expect(getByRole('button', { name: 'Delete' })).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsRow.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsRow.tsx new file mode 100644 index 00000000000..5f5c75617e9 --- /dev/null +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsRow.tsx @@ -0,0 +1,110 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; + +import { Hidden } from 'src/components/Hidden'; +import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; +import { List } from 'src/components/List'; +import { ListItem } from 'src/components/ListItem'; +import { TableCell } from 'src/components/TableCell'; +import { TableRow } from 'src/components/TableRow'; +import { TextTooltip } from 'src/components/TextTooltip'; +import { Typography } from 'src/components/Typography'; +import { useLinodesQuery } from 'src/queries/linodes/linodes'; +import { useRegionsQuery } from 'src/queries/regions'; + +import { StyledWarningIcon } from './PlacementGroupsRow.styles'; + +import type { PlacementGroup } from '@linode/api-v4'; +import type { Action } from 'src/components/ActionMenu/ActionMenu'; + +interface PlacementGroupsRowProps { + handleDeletePlacementGroup: () => void; + handleRenamePlacementGroup: () => void; + placementGroup: PlacementGroup; +} + +export const PlacementGroupsRow = React.memo( + ({ + handleDeletePlacementGroup, + handleRenamePlacementGroup, + placementGroup, + }: PlacementGroupsRowProps) => { + const { + affinity_type, + compliant, + id, + label, + limits, + linode_ids, + } = placementGroup; + const { data: regions } = useRegionsQuery(); + const { data: linodes } = useLinodesQuery(); + const regionLabel = + regions?.find((region) => region.id === placementGroup.region)?.label ?? + placementGroup.region; + const numberOfAssignedLinodesAsString = linode_ids.length.toString() ?? ''; + const listOfAssignedLinodes = linodes?.data.filter((linode) => + linode_ids.includes(linode.id) + ); + const affinityType = + affinity_type === 'affinity' ? 'Affinity' : 'Anti-affinity'; + const actions: Action[] = [ + { + onClick: handleRenamePlacementGroup, + title: 'Rename', + }, + { + onClick: handleDeletePlacementGroup, + title: 'Delete', + }, + ]; + + return ( + + + + {label} ({affinityType})   + + {!compliant && ( + + + Non-compliant + + )} + + + + {listOfAssignedLinodes?.map((linode, idx) => ( + {linode.label} + ))} + + } + displayText={numberOfAssignedLinodesAsString} + minWidth={200} + /> +   of {limits} + + + {regionLabel} + + + {actions.map((action) => ( + + ))} + + + ); + } +); diff --git a/packages/manager/src/features/PlacementGroups/constants.ts b/packages/manager/src/features/PlacementGroups/constants.ts new file mode 100644 index 00000000000..156376c24eb --- /dev/null +++ b/packages/manager/src/features/PlacementGroups/constants.ts @@ -0,0 +1 @@ +export const MAX_NUMBER_OF_PLACEMENT_GROUPS = 5; diff --git a/packages/manager/src/features/PlacementGroups/index.tsx b/packages/manager/src/features/PlacementGroups/index.tsx new file mode 100644 index 00000000000..5328452cebb --- /dev/null +++ b/packages/manager/src/features/PlacementGroups/index.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import { Route, Switch, useRouteMatch } from 'react-router-dom'; + +import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { ProductInformationBanner } from 'src/components/ProductInformationBanner/ProductInformationBanner'; +import { SuspenseLoader } from 'src/components/SuspenseLoader'; + +const PlacementGroupsLanding = React.lazy(() => + import('./PlacementGroupsLanding/PlacementGroupsLanding').then((module) => ({ + default: module.PlacementGroupsLanding, + })) +); + +// TODO VM_Placement: add +// const PlacementGroupDetail = React.lazy(() => +// import('./PlacementGroupDetail/PlacementGroupDetail').then((module) => ({ +// default: module.PlacementGroupsDetail, +// })) +// ); + +export const PlacementGroups = () => { + const { path } = useRouteMatch(); + + return ( + }> + + + + + + {/* + // TODO VM_Placement: add + + */} + + + + + ); +}; From e4e38f987ff7641aa0b33f2ff695ad05699467fb Mon Sep 17 00:00:00 2001 From: jdamore-linode <97627410+jdamore-linode@users.noreply.github.com> Date: Fri, 19 Jan 2024 09:28:40 -0500 Subject: [PATCH 02/44] test: [M3-7498] - Add Cypress tests for Parent/Child billing page UX enhancements (#10070) * Upgrade Vitest from 1.0.x to 1.2.x * Enable contact info unit test that was fixed by Vitest upgrade * Disable payment method action menu items for restricted and child users * Add QA data attribute to Tooltip component * Add intercept util for profile grants request * Add tooltip UI helper * Add Cypress tests for Parent/Child restricted and child user billing UX --------- Co-authored-by: Jaalah Ramos --- .../pr-10070-tests-1705515017364.md | 5 + .../pr-10070-tests-1705515031938.md | 5 + .../billing/restricted-user-billing.spec.ts | 339 ++++++++++++++++++ .../cypress/support/intercepts/profile.ts | 20 +- packages/manager/cypress/support/ui/index.ts | 2 + .../manager/cypress/support/ui/tooltip.ts | 11 + packages/manager/package.json | 2 +- .../PaymentMethodRow/PaymentMethodRow.tsx | 15 +- packages/manager/src/components/Tooltip.tsx | 2 +- .../ContactInformation.test.tsx | 45 ++- .../PaymentInfoPanel/PaymentInformation.tsx | 2 + .../PaymentInfoPanel/PaymentMethods.tsx | 6 + .../src/features/Billing/billingUtils.ts | 2 +- .../manager/src/features/Billing/constants.ts | 2 +- yarn.lock | 92 +++-- 15 files changed, 484 insertions(+), 66 deletions(-) create mode 100644 packages/manager/.changeset/pr-10070-tests-1705515017364.md create mode 100644 packages/manager/.changeset/pr-10070-tests-1705515031938.md create mode 100644 packages/manager/cypress/e2e/core/billing/restricted-user-billing.spec.ts create mode 100644 packages/manager/cypress/support/ui/tooltip.ts diff --git a/packages/manager/.changeset/pr-10070-tests-1705515017364.md b/packages/manager/.changeset/pr-10070-tests-1705515017364.md new file mode 100644 index 00000000000..b945522d1ce --- /dev/null +++ b/packages/manager/.changeset/pr-10070-tests-1705515017364.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add Cypress tests for restricted user billing flows ([#10070](https://github.com/linode/manager/pull/10070)) diff --git a/packages/manager/.changeset/pr-10070-tests-1705515031938.md b/packages/manager/.changeset/pr-10070-tests-1705515031938.md new file mode 100644 index 00000000000..d731be27795 --- /dev/null +++ b/packages/manager/.changeset/pr-10070-tests-1705515031938.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Upgrade to Vitest 1.2.0 ([#10070](https://github.com/linode/manager/pull/10070)) diff --git a/packages/manager/cypress/e2e/core/billing/restricted-user-billing.spec.ts b/packages/manager/cypress/e2e/core/billing/restricted-user-billing.spec.ts new file mode 100644 index 00000000000..370be9b3c05 --- /dev/null +++ b/packages/manager/cypress/e2e/core/billing/restricted-user-billing.spec.ts @@ -0,0 +1,339 @@ +/** + * @file Integration tests for restricted user billing flows. + */ + +import { paymentMethodFactory, profileFactory } from '@src/factories'; +import { accountUserFactory } from '@src/factories/accountUsers'; +import { grantsFactory } from '@src/factories/grants'; +import { mockGetPaymentMethods, mockGetUser } from 'support/intercepts/account'; +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; +import { + mockGetProfile, + mockGetProfileGrants, +} from 'support/intercepts/profile'; +import { ui } from 'support/ui'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { randomLabel } from 'support/util/random'; + +// Tooltip message that appears on disabled billing action buttons for restricted users. +const restrictedUserTooltip = + 'To modify this content, please contact your administrator.'; + +// Tooltip message that appears on disabled billing action buttons for child users. +const childUserTooltip = + 'To modify this content, please contact your business partner.'; + +// Mock credit card payment method to use in tests. +const mockPaymentMethods = [ + paymentMethodFactory.build({ + data: { + card_type: 'Visa', + expiry: '12/2026', + last_four: '1234', + }, + is_default: false, + }), + paymentMethodFactory.build({ + data: { + card_type: 'Visa', + expiry: '12/2026', + last_four: '5678', + }, + is_default: true, + }), +]; + +/** + * Asserts that the billing contact "Edit" button is disabled. + * + * Additionally confirms that clicking the "Edit" button reveals a tooltip and + * does not open the "Edit Billing Contact Info" drawer. + * + * @param tooltipText - Expected tooltip message to be shown to the user. + */ +const assertEditBillingInfoDisabled = (tooltipText: string) => { + // Confirm Billing Contact section "Edit" button is disabled, then click it. + cy.get('[data-qa-contact-summary]') + .should('be.visible') + .within(() => { + ui.button + .findByTitle('Edit') + .should('be.visible') + .should('be.disabled') + .click(); + }); + + // Assert that "Edit Contact Billing Info" drawer does not open and that tooltip is revealed. + cy.get(`[data-qa-drawer-title="Edit Billing Contact Info"]`).should( + 'not.exist' + ); + ui.tooltip.findByText(tooltipText).should('be.visible'); +}; + +/** + * Asserts that the billing contact "Edit" button is enabled. + * + * Additionally confirms that clicking the "Edit" button opens the "Edit Billing + * Contact Info" drawer, then closes the drawer. + */ +const assertEditBillingInfoEnabled = () => { + cy.get('[data-qa-contact-summary]') + .should('be.visible') + .within(() => { + ui.button + .findByTitle('Edit') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + ui.drawer + .findByTitle('Edit Billing Contact Info') + .should('be.visible') + .within(() => { + ui.drawerCloseButton.find().click(); + }); +}; + +/** + * Asserts that the "Add Payment Method" button is disabled. + * + * Additionally confirms that clicking the "Add Payment Method" button reveals + * a tooltip and does not open the "Add Payment Method" drawer. + * + * @param tooltipText - Expected tooltip message to be shown to the user. + */ +const assertAddPaymentMethodDisabled = (tooltipText: string) => { + // Confirm that payment method action menu items are disabled. + ui.actionMenu + .findByTitle('Action menu for card ending in 1234') + .should('be.visible') + .should('be.enabled') + .click(); + + ['Make a Payment', 'Make Default', 'Delete'].forEach((menuItem: string) => { + ui.actionMenuItem.findByTitle(menuItem).should('be.disabled'); + }); + + // Dismiss action menu. + cy.get('[data-qa-action-menu="true"]').click(); + + // Confirm Billing Summary section "Add Payment Method" button is disabled, then click it. + cy.get('[data-qa-billing-summary]') + .should('be.visible') + .within(() => { + ui.button + .findByTitle('Add Payment Method') + .should('be.visible') + .should('be.disabled') + .click(); + }); + + // Assert that "Add Payment Method" drawer does not open and that tooltip is revealed. + cy.get(`[data-qa-drawer-title="Add Payment Method"]`).should('not.exist'); + ui.tooltip.findByText(tooltipText).should('be.visible'); +}; + +/** + * Asserts that the "Add Payment Method" button is enabled. + * + * Additionally confirms that clicking the "Add Payment Method" button opens the + * "Add Payment Method" drawer, then closes the drawer. + */ +const assertAddPaymentMethodEnabled = () => { + // Confirm that payment method action menu items are enabled. + ui.actionMenu + .findByTitle('Action menu for card ending in 1234') + .should('be.visible') + .should('be.enabled') + .click(); + + ['Make a Payment', 'Make Default', 'Delete'].forEach((menuItem: string) => { + ui.actionMenuItem.findByTitle(menuItem).should('be.enabled'); + }); + + // Dismiss action menu. + cy.get('[data-qa-action-menu="true"]').click(); + + cy.get('[data-qa-billing-summary]') + .should('be.visible') + .within(() => { + ui.button + .findByTitle('Add Payment Method') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + ui.drawer + .findByTitle('Add Payment Method') + .should('be.visible') + .within(() => { + ui.drawerCloseButton.find().click(); + }); +}; + +describe('restricted user billing flows', () => { + beforeEach(() => { + mockGetPaymentMethods(mockPaymentMethods); + }); + + // TODO Delete all of these tests when Parent/Child launches and flag is removed. + describe('Parent/Child feature disabled', () => { + beforeEach(() => { + // Mock the Parent/Child feature flag to be enabled. + mockAppendFeatureFlags({ + parentChildAccountAccess: makeFeatureFlagData(false), + }); + mockGetFeatureFlagClientstream(); + }); + + /* + * - Smoke test to confirm that regular users can edit billing information. + * - Confirms that billing action buttons are enabled and open their respective drawers on click. + * - Confirms that payment method action menu items are enabled. + */ + it('can edit billing information', () => { + // The flow prior to Parent/Child does not account for user privileges, instead relying + // on the API to forbid actions when the user does not have the required privileges. + // Because the API is doing the heavy lifting, we only need to ensure that the billing action + // buttons behave as expected for this smoke test. + const mockProfile = profileFactory.build({ + username: randomLabel(), + restricted: false, + }); + + const mockUser = accountUserFactory.build({ + username: mockProfile.username, + user_type: null, + restricted: false, + }); + + // Confirm button behavior for regular users. + mockGetProfile(mockProfile); + mockGetUser(mockUser); + cy.visitWithLogin('/account/billing'); + assertEditBillingInfoEnabled(); + assertAddPaymentMethodEnabled(); + }); + }); + + describe('Parent/Child feature enabled', () => { + beforeEach(() => { + // Mock the Parent/Child feature flag to be enabled. + // TODO Delete this `beforeEach()` block when Parent/Child launches and flag is removed. + mockAppendFeatureFlags({ + parentChildAccountAccess: makeFeatureFlagData(true), + }); + mockGetFeatureFlagClientstream(); + }); + + /* + * - Confirms that users with read-only account access cannot edit billing information. + * - Confirms UX enhancements are applied when parent/child feature flag is enabled. + * - Confirms that "Edit" and "Add Payment Method" buttons are disabled and have informational tooltips. + * - Confirms that clicking "Edit" and "Add Payment Method" does not open their respective drawers when disabled. + * - Confirms that button tooltip text reflects read-only account access. + * - Confirms that payment method action menu items are disabled. + */ + it('cannot edit billing information with read-only account access', () => { + const mockProfile = profileFactory.build({ + username: randomLabel(), + restricted: true, + }); + + const mockUser = accountUserFactory.build({ + username: mockProfile.username, + restricted: true, + user_type: null, + }); + + const mockGrants = grantsFactory.build({ + global: { + account_access: 'read_only', + }, + }); + + mockGetProfile(mockProfile); + mockGetProfileGrants(mockGrants); + mockGetUser(mockUser); + cy.visitWithLogin('/account/billing'); + + assertEditBillingInfoDisabled(restrictedUserTooltip); + assertAddPaymentMethodDisabled(restrictedUserTooltip); + }); + + /* + * - Confirms that child users cannot edit billing information. + * - Confirms that UX enhancements are applied when parent/child feature flag is enabled. + * - Confirms that "Edit" and "Add Payment Method" buttons are disabled and have informational tooltips. + * - Confirms that clicking "Edit" and "Add Payment Method" does not open their respective drawers when disabled. + * - Confirms that button tooltip text reflects child user access. + * - Confirms that payment method action menu items are disabled. + */ + it('cannot edit billing information as child account', () => { + const mockProfile = profileFactory.build({ + username: randomLabel(), + }); + + const mockUser = accountUserFactory.build({ + username: mockProfile.username, + user_type: 'child', + }); + + mockGetProfile(mockProfile); + mockGetUser(mockUser); + cy.visitWithLogin('/account/billing'); + + assertEditBillingInfoDisabled(childUserTooltip); + assertAddPaymentMethodDisabled(childUserTooltip); + }); + + /* + * - Smoke test to confirm that regular and parent users can edit billing information. + * - Confirms that billing action buttons are enabled and open their respective drawers on click. + */ + it('can edit billing information as a regular user and as a parent user', () => { + const mockProfileRegular = profileFactory.build({ + username: randomLabel(), + restricted: false, + }); + + const mockUserRegular = accountUserFactory.build({ + username: mockProfileRegular.username, + user_type: null, + restricted: false, + }); + + const mockProfileParent = profileFactory.build({ + username: randomLabel(), + restricted: false, + }); + + const mockUserParent = accountUserFactory.build({ + username: mockProfileParent.username, + user_type: 'parent', + restricted: false, + }); + + // Confirm button behavior for regular users. + mockGetProfile(mockProfileRegular); + mockGetUser(mockUserRegular); + cy.visitWithLogin('/account/billing'); + cy.findByText(mockProfileRegular.username); + assertEditBillingInfoEnabled(); + assertAddPaymentMethodEnabled(); + + // Confirm button behavior for parent users. + mockGetProfile(mockProfileParent); + mockGetUser(mockUserParent); + cy.visitWithLogin('/account/billing'); + cy.findByText(mockProfileParent.username); + assertEditBillingInfoEnabled(); + assertAddPaymentMethodEnabled(); + }); + }); +}); diff --git a/packages/manager/cypress/support/intercepts/profile.ts b/packages/manager/cypress/support/intercepts/profile.ts index 6a83e58ef51..60e6313fdca 100644 --- a/packages/manager/cypress/support/intercepts/profile.ts +++ b/packages/manager/cypress/support/intercepts/profile.ts @@ -8,6 +8,7 @@ import { paginateResponse } from 'support/util/paginate'; import { makeResponse } from 'support/util/response'; import type { + Grants, OAuthClient, Profile, SecurityQuestionsData, @@ -46,7 +47,24 @@ export const mockGetProfile = (profile: Profile): Cypress.Chainable => { export const mockUpdateProfile = ( profile: Profile ): Cypress.Chainable => { - return cy.intercept('PUT', apiMatcher(`profile`), makeResponse(profile)); + return cy.intercept('PUT', apiMatcher('profile'), makeResponse(profile)); +}; + +/** + * Intercepts GET request to fetch profile grants and mocks response. + * + * @param grants - Grants object with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockGetProfileGrants = ( + grants: Grants +): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher('profile/grants'), + makeResponse(grants) + ); }; /** diff --git a/packages/manager/cypress/support/ui/index.ts b/packages/manager/cypress/support/ui/index.ts index 22044a1c082..26d8b89ac17 100644 --- a/packages/manager/cypress/support/ui/index.ts +++ b/packages/manager/cypress/support/ui/index.ts @@ -15,6 +15,7 @@ import * as select from './select'; import * as tabList from './tab-list'; import * as toast from './toast'; import * as toggle from './toggle'; +import * as tooltip from './tooltip'; import * as userMenu from './user-menu'; export const ui = { @@ -35,5 +36,6 @@ export const ui = { ...toast, ...tabList, ...toggle, + ...tooltip, ...userMenu, }; diff --git a/packages/manager/cypress/support/ui/tooltip.ts b/packages/manager/cypress/support/ui/tooltip.ts new file mode 100644 index 00000000000..afd2aba84fc --- /dev/null +++ b/packages/manager/cypress/support/ui/tooltip.ts @@ -0,0 +1,11 @@ +/** + * Tooltip UI helper. + */ +export const tooltip = { + /** + * Finds a tooltip that has the given text. + */ + findByText: (text: string): Cypress.Chainable => { + return cy.document().its('body').find(`[data-qa-tooltip="${text}"]`); + }, +}; diff --git a/packages/manager/package.json b/packages/manager/package.json index 52fb9ecdc28..42616f772c0 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -209,7 +209,7 @@ "ts-node": "^10.9.2", "vite": "^5.0.7", "vite-plugin-svgr": "^3.2.0", - "vitest": "^1.0.4" + "vitest": "^1.2.0" }, "browserslist": [ ">1%", diff --git a/packages/manager/src/components/PaymentMethodRow/PaymentMethodRow.tsx b/packages/manager/src/components/PaymentMethodRow/PaymentMethodRow.tsx index fe28b09b3dc..93fee8560f6 100644 --- a/packages/manager/src/components/PaymentMethodRow/PaymentMethodRow.tsx +++ b/packages/manager/src/components/PaymentMethodRow/PaymentMethodRow.tsx @@ -16,6 +16,14 @@ import { queryKey } from 'src/queries/accountPayment'; import { ThirdPartyPayment } from './ThirdPartyPayment'; interface Props { + /** + * Whether the user is a child user. + */ + isChildUser?: boolean | undefined; + /** + * Whether the user is a restricted user. + */ + isRestrictedUser?: boolean | undefined; /** * Function called when the delete button in the Action Menu is pressed. */ @@ -32,7 +40,7 @@ interface Props { */ export const PaymentMethodRow = (props: Props) => { const theme = useTheme(); - const { onDelete, paymentMethod } = props; + const { isRestrictedUser, onDelete, paymentMethod } = props; const { is_default, type } = paymentMethod; const history = useHistory(); const { enqueueSnackbar } = useSnackbar(); @@ -51,6 +59,7 @@ export const PaymentMethodRow = (props: Props) => { const actions: Action[] = [ { + disabled: isRestrictedUser, onClick: () => { history.push({ pathname: '/account/billing/make-payment/', @@ -60,7 +69,7 @@ export const PaymentMethodRow = (props: Props) => { title: 'Make a Payment', }, { - disabled: paymentMethod.is_default, + disabled: isRestrictedUser || paymentMethod.is_default, onClick: () => makeDefault(paymentMethod.id), title: 'Make Default', tooltip: paymentMethod.is_default @@ -68,7 +77,7 @@ export const PaymentMethodRow = (props: Props) => { : undefined, }, { - disabled: paymentMethod.is_default, + disabled: isRestrictedUser || paymentMethod.is_default, onClick: onDelete, title: 'Delete', tooltip: paymentMethod.is_default diff --git a/packages/manager/src/components/Tooltip.tsx b/packages/manager/src/components/Tooltip.tsx index 0c575080fee..c325f07cc4d 100644 --- a/packages/manager/src/components/Tooltip.tsx +++ b/packages/manager/src/components/Tooltip.tsx @@ -7,7 +7,7 @@ import type { TooltipProps } from '@mui/material/Tooltip'; * Tooltips display informative text when users hover over, focus on, or tap an element. */ export const Tooltip = (props: TooltipProps) => { - return <_Tooltip {...props} />; + return <_Tooltip data-qa-tooltip={props.title} {...props} />; }; export { tooltipClasses }; export type { TooltipProps }; diff --git a/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.test.tsx b/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.test.tsx index 6904733f08c..653217ba7a1 100644 --- a/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.test.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.test.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; +import { grantsFactory } from 'src/factories/grants'; import { accountUserFactory } from 'src/factories/accountUsers'; import { renderWithTheme } from 'src/utilities/testHelpers'; @@ -35,14 +36,13 @@ vi.mock('src/queries/accountUsers', async () => { }; }); -// TODO: When we figure out issue with Vitest circular dependencies -// vi.mock('src/queries/profile', async () => { -// const actual = await vi.importActual('src/queries/profile'); -// return { -// ...actual, -// useGrants: queryMocks.useGrants, -// }; -// }); +vi.mock('src/queries/profile', async () => { + const actual = await vi.importActual('src/queries/profile'); + return { + ...actual, + useGrants: queryMocks.useGrants, + }; +}); queryMocks.useAccountUser.mockReturnValue({ data: accountUserFactory.build({ user_type: 'parent' }), @@ -64,21 +64,20 @@ describe('Edit Contact Information', () => { ); }); - // TODO: When we figure out issue with Vitest circular dependencies - // it('should be disabled for non-parent/child restricted users', () => { - // queryMocks.useGrants.mockReturnValue({ - // data: grantsFactory.build({ - // global: { - // account_access: 'read_only', - // }, - // }), - // }); + it('should be disabled for non-parent/child restricted users', () => { + queryMocks.useGrants.mockReturnValue({ + data: grantsFactory.build({ + global: { + account_access: 'read_only', + }, + }), + }); - // const { getByTestId } = renderWithTheme(); + const { getByTestId } = renderWithTheme(); - // expect(getByTestId(EDIT_BUTTON_ID)).toHaveAttribute( - // 'aria-disabled', - // 'true' - // ); - // }); + expect(getByTestId(EDIT_BUTTON_ID)).toHaveAttribute( + 'aria-disabled', + 'true' + ); + }); }); diff --git a/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentInformation.tsx b/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentInformation.tsx index 30a71314c5e..edfdb3a76cd 100644 --- a/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentInformation.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentInformation.tsx @@ -123,6 +123,8 @@ const PaymentInformation = (props: Props) => { {!isAkamaiCustomer ? ( void; paymentMethods: PaymentMethod[] | undefined; @@ -17,6 +19,8 @@ interface Props { const PaymentMethods = ({ error, + isChildUser, + isRestrictedUser, loading, openDeleteDialog, paymentMethods, @@ -59,6 +63,8 @@ const PaymentMethods = ({ <> {paymentMethods.map((paymentMethod: PaymentMethod) => ( openDeleteDialog(paymentMethod)} paymentMethod={paymentMethod} diff --git a/packages/manager/src/features/Billing/billingUtils.ts b/packages/manager/src/features/Billing/billingUtils.ts index 964f11f8eba..1b28b49429c 100644 --- a/packages/manager/src/features/Billing/billingUtils.ts +++ b/packages/manager/src/features/Billing/billingUtils.ts @@ -71,5 +71,5 @@ export const getDisabledTooltipText = ({ ? `${RESTRICTED_SECTION_EDIT_MESSAGE} ${ isChildUser ? BUSINESS_PARTNER : ADMINISTRATOR }.` - : undefined; + : ''; }; diff --git a/packages/manager/src/features/Billing/constants.ts b/packages/manager/src/features/Billing/constants.ts index a0e5902c360..22ddbf7255f 100644 --- a/packages/manager/src/features/Billing/constants.ts +++ b/packages/manager/src/features/Billing/constants.ts @@ -1,4 +1,4 @@ export const RESTRICTED_SECTION_EDIT_MESSAGE = - 'To edit this section, please contact your'; + 'To modify this content, please contact your'; export const ADD_PAYMENT_METHOD = 'Add Payment Method'; export const EDIT_BILLING_CONTACT = 'Edit'; diff --git a/yarn.lock b/yarn.lock index 98aceb8e809..6035d09a326 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5244,13 +5244,13 @@ "@vitest/utils" "1.0.1" chai "^4.3.10" -"@vitest/expect@1.0.4": - version "1.0.4" - resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-1.0.4.tgz#2751018b6e527841043e046ff424304453a0a024" - integrity sha512-/NRN9N88qjg3dkhmFcCBwhn/Ie4h064pY3iv7WLRsDJW7dXnEgeoa8W9zy7gIPluhz6CkgqiB3HmpIXgmEY5dQ== +"@vitest/expect@1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-1.2.0.tgz#de93f5c32c2781c41415a8c3a6e48e1c023d6613" + integrity sha512-H+2bHzhyvgp32o7Pgj2h9RTHN0pgYaoi26Oo3mE+dCi1PAqV31kIIVfTbqMO3Bvshd5mIrJLc73EwSRrbol9Lw== dependencies: - "@vitest/spy" "1.0.4" - "@vitest/utils" "1.0.4" + "@vitest/spy" "1.2.0" + "@vitest/utils" "1.2.0" chai "^4.3.10" "@vitest/runner@1.0.1": @@ -5262,12 +5262,12 @@ p-limit "^5.0.0" pathe "^1.1.1" -"@vitest/runner@1.0.4": - version "1.0.4" - resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-1.0.4.tgz#c4dcb88c07f40b91293ff1331747ee58fad6d5e4" - integrity sha512-rhOQ9FZTEkV41JWXozFM8YgOqaG9zA7QXbhg5gy6mFOVqh4PcupirIJ+wN7QjeJt8S8nJRYuZH1OjJjsbxAXTQ== +"@vitest/runner@1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-1.2.0.tgz#84775f0f5c48620ff1943a45c19863355791c6d9" + integrity sha512-vaJkDoQaNUTroT70OhM0NPznP7H3WyRwt4LvGwCVYs/llLaqhoSLnlIhUClZpbF5RgAee29KRcNz0FEhYcgxqA== dependencies: - "@vitest/utils" "1.0.4" + "@vitest/utils" "1.2.0" p-limit "^5.0.0" pathe "^1.1.1" @@ -5280,10 +5280,10 @@ pathe "^1.1.1" pretty-format "^29.7.0" -"@vitest/snapshot@1.0.4": - version "1.0.4" - resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-1.0.4.tgz#7020983b3963b473237fea08d347ea83b266b9bb" - integrity sha512-vkfXUrNyNRA/Gzsp2lpyJxh94vU2OHT1amoD6WuvUAA12n32xeVZQ0KjjQIf8F6u7bcq2A2k969fMVxEsxeKYA== +"@vitest/snapshot@1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-1.2.0.tgz#2fcddb5c6e8a9d2fc9f18ea2f8fd39b1b6e691b4" + integrity sha512-P33EE7TrVgB3HDLllrjK/GG6WSnmUtWohbwcQqmm7TAk9AVHpdgf7M3F3qRHKm6vhr7x3eGIln7VH052Smo6Kw== dependencies: magic-string "^0.30.5" pathe "^1.1.1" @@ -5296,10 +5296,10 @@ dependencies: tinyspy "^2.2.0" -"@vitest/spy@1.0.4": - version "1.0.4" - resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-1.0.4.tgz#e182c78fb9b1178ff789ad7eb4560ba6750e6e9b" - integrity sha512-9ojTFRL1AJVh0hvfzAQpm0QS6xIS+1HFIw94kl/1ucTfGCaj1LV/iuJU4Y6cdR03EzPDygxTHwE1JOm+5RCcvA== +"@vitest/spy@1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-1.2.0.tgz#61104de4c19a3addefff021d884c9e20dc17ebcd" + integrity sha512-MNxSAfxUaCeowqyyGwC293yZgk7cECZU9wGb8N1pYQ0yOn/SIr8t0l9XnGRdQZvNV/ZHBYu6GO/W3tj5K3VN1Q== dependencies: tinyspy "^2.2.0" @@ -5334,6 +5334,16 @@ loupe "^2.3.7" pretty-format "^29.7.0" +"@vitest/utils@1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-1.2.0.tgz#deb9bdc3d094bf47f93a592a6a0b3946aa575e7a" + integrity sha512-FyD5bpugsXlwVpTcGLDf3wSPYy8g541fQt14qtzo8mJ4LdEpDKZ9mQy2+qdJm2TZRpjY5JLXihXCgIxiRJgi5g== + dependencies: + diff-sequences "^29.6.3" + estree-walker "^3.0.3" + loupe "^2.3.7" + pretty-format "^29.7.0" + "@xmldom/xmldom@^0.8.3": version "0.8.10" resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.8.10.tgz#a1337ca426aa61cef9fe15b5b28e340a72f6fa99" @@ -5410,6 +5420,11 @@ acorn-walk@^8.3.0: resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.3.0.tgz#2097665af50fd0cf7a2dfccd2b9368964e66540f" integrity sha512-FS7hV565M5l1R08MXqo8odwMTB02C2UqzB17RVgu9EyuYFBqJZ3/ZY97sQD5FewVu1UyDFc1yztUDrAwT0EypA== +acorn-walk@^8.3.1: + version "8.3.2" + resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.3.2.tgz#7703af9415f1b6db9315d6895503862e231d34aa" + integrity sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A== + acorn@^7.1.1, acorn@^7.4.1: version "7.4.1" resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" @@ -8128,6 +8143,13 @@ estree-walker@^2.0.2: resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac" integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w== +estree-walker@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-3.0.3.tgz#67c3e549ec402a487b4fc193d1953a524752340d" + integrity sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g== + dependencies: + "@types/estree" "^1.0.0" + esutils@^2.0.2: version "2.0.3" resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" @@ -14431,10 +14453,10 @@ vite-node@1.0.1: picocolors "^1.0.0" vite "^5.0.0-beta.15 || ^5.0.0" -vite-node@1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-1.0.4.tgz#36d6c49e3b5015967d883845561ed67abe6553cc" - integrity sha512-9xQQtHdsz5Qn8hqbV7UKqkm8YkJhzT/zr41Dmt5N7AlD8hJXw/Z7y0QiD5I8lnTthV9Rvcvi0QW7PI0Fq83ZPg== +vite-node@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-1.2.0.tgz#9a359804469203a54ac49daad3065f2fd0bfb9c3" + integrity sha512-ETnQTHeAbbOxl7/pyBck9oAPZZZo+kYnFt1uQDD+hPReOc+wCjXw4r4jHriBRuVDB5isHmPXxrfc1yJnfBERqg== dependencies: cac "^6.7.14" debug "^4.3.4" @@ -14500,17 +14522,17 @@ vitest@^1.0.1: vite-node "1.0.1" why-is-node-running "^2.2.2" -vitest@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/vitest/-/vitest-1.0.4.tgz#c4b39ba4fcba674499c90e28f4d8dd16fa1d4eb3" - integrity sha512-s1GQHp/UOeWEo4+aXDOeFBJwFzL6mjycbQwwKWX2QcYfh/7tIerS59hWQ20mxzupTJluA2SdwiBuWwQHH67ckg== - dependencies: - "@vitest/expect" "1.0.4" - "@vitest/runner" "1.0.4" - "@vitest/snapshot" "1.0.4" - "@vitest/spy" "1.0.4" - "@vitest/utils" "1.0.4" - acorn-walk "^8.3.0" +vitest@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/vitest/-/vitest-1.2.0.tgz#2ddff4a32ed992339655f243525c0e187b5af6d9" + integrity sha512-Ixs5m7BjqvLHXcibkzKRQUvD/XLw0E3rvqaCMlrm/0LMsA0309ZqYvTlPzkhh81VlEyVZXFlwWnkhb6/UMtcaQ== + dependencies: + "@vitest/expect" "1.2.0" + "@vitest/runner" "1.2.0" + "@vitest/snapshot" "1.2.0" + "@vitest/spy" "1.2.0" + "@vitest/utils" "1.2.0" + acorn-walk "^8.3.1" cac "^6.7.14" chai "^4.3.10" debug "^4.3.4" @@ -14524,7 +14546,7 @@ vitest@^1.0.4: tinybench "^2.5.1" tinypool "^0.8.1" vite "^5.0.0" - vite-node "1.0.4" + vite-node "1.2.0" why-is-node-running "^2.2.2" w3c-xmlserializer@^4.0.0: From d938bfed3efaab5ec0916a452d3ae877738f645a Mon Sep 17 00:00:00 2001 From: Jaalah Ramos <125309814+jaalah-akamai@users.noreply.github.com> Date: Fri, 19 Jan 2024 16:11:10 -0500 Subject: [PATCH 03/44] upcoming: [M3-7659] - Add `user_type` to `/profile` endpoint (#10080) Co-authored-by: Jaalah Ramos Co-authored-by: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> --- ...r-10080-upcoming-features-1705618109652.md | 5 ++ packages/api-v4/src/profile/types.ts | 3 + ...r-10080-upcoming-features-1705618128252.md | 5 ++ .../billing/restricted-user-billing.spec.ts | 2 +- packages/manager/src/factories/profile.ts | 1 + .../src/features/Billing/BillingDetail.tsx | 5 ++ .../ContactInformation.test.tsx | 38 +++++----- .../ContactInfoPanel/ContactInformation.tsx | 12 +-- .../PaymentInformation.test.tsx | 76 +++++-------------- .../PaymentInfoPanel/PaymentInformation.tsx | 13 ++-- packages/manager/src/mocks/serverHandlers.ts | 2 + 11 files changed, 74 insertions(+), 88 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-10080-upcoming-features-1705618109652.md create mode 100644 packages/manager/.changeset/pr-10080-upcoming-features-1705618128252.md diff --git a/packages/api-v4/.changeset/pr-10080-upcoming-features-1705618109652.md b/packages/api-v4/.changeset/pr-10080-upcoming-features-1705618109652.md new file mode 100644 index 00000000000..6fc3fdf87d1 --- /dev/null +++ b/packages/api-v4/.changeset/pr-10080-upcoming-features-1705618109652.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Upcoming Features +--- + +Add `user_type` to /profile endpoint for Parent/Child user roles ([#10080](https://github.com/linode/manager/pull/10080)) diff --git a/packages/api-v4/src/profile/types.ts b/packages/api-v4/src/profile/types.ts index 49d16124f26..0d7ea3347f3 100644 --- a/packages/api-v4/src/profile/types.ts +++ b/packages/api-v4/src/profile/types.ts @@ -1,3 +1,5 @@ +import type { UserType } from '../account'; + export interface Referrals { code: string; url: string; @@ -22,6 +24,7 @@ export interface Profile { two_factor_auth: boolean; restricted: boolean; verified_phone_number: string | null; + user_type: UserType | null; } export interface TokenRequest { diff --git a/packages/manager/.changeset/pr-10080-upcoming-features-1705618128252.md b/packages/manager/.changeset/pr-10080-upcoming-features-1705618128252.md new file mode 100644 index 00000000000..612dc91a274 --- /dev/null +++ b/packages/manager/.changeset/pr-10080-upcoming-features-1705618128252.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add `user_type` to /profile endpoint for Parent/Child user roles ([#10080](https://github.com/linode/manager/pull/10080)) diff --git a/packages/manager/cypress/e2e/core/billing/restricted-user-billing.spec.ts b/packages/manager/cypress/e2e/core/billing/restricted-user-billing.spec.ts index 370be9b3c05..5a17dd052d0 100644 --- a/packages/manager/cypress/e2e/core/billing/restricted-user-billing.spec.ts +++ b/packages/manager/cypress/e2e/core/billing/restricted-user-billing.spec.ts @@ -277,11 +277,11 @@ describe('restricted user billing flows', () => { it('cannot edit billing information as child account', () => { const mockProfile = profileFactory.build({ username: randomLabel(), + user_type: 'child', }); const mockUser = accountUserFactory.build({ username: mockProfile.username, - user_type: 'child', }); mockGetProfile(mockProfile); diff --git a/packages/manager/src/factories/profile.ts b/packages/manager/src/factories/profile.ts index bb16f9a44bf..86fd7603653 100644 --- a/packages/manager/src/factories/profile.ts +++ b/packages/manager/src/factories/profile.ts @@ -25,6 +25,7 @@ export const profileFactory = Factory.Sync.makeFactory({ timezone: 'Asia/Shanghai', two_factor_auth: false, uid: 9999, + user_type: null, username: 'mock-user', verified_phone_number: '+15555555555', }); diff --git a/packages/manager/src/features/Billing/BillingDetail.tsx b/packages/manager/src/features/Billing/BillingDetail.tsx index db6fbcb6489..07b4ce95396 100644 --- a/packages/manager/src/features/Billing/BillingDetail.tsx +++ b/packages/manager/src/features/Billing/BillingDetail.tsx @@ -11,6 +11,7 @@ import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { PAYPAL_CLIENT_ID } from 'src/constants'; import { useAccount } from 'src/queries/account'; import { useAllPaymentMethodsQuery } from 'src/queries/accountPayment'; +import { useProfile } from 'src/queries/profile'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import BillingActivityPanel from './BillingPanels/BillingActivityPanel/BillingActivityPanel'; @@ -31,6 +32,8 @@ export const BillingDetail = () => { isLoading: accountLoading, } = useAccount(); + const { data: profile } = useProfile(); + if (accountLoading) { return ; } @@ -75,6 +78,7 @@ export const BillingDetail = () => { firstName={account.first_name} lastName={account.last_name} phone={account.phone} + profile={profile} state={account.state} taxId={account.tax_id} zip={account.zip} @@ -84,6 +88,7 @@ export const BillingDetail = () => { isAkamaiCustomer={account?.billing_source === 'akamai'} loading={paymentMethodsLoading} paymentMethods={paymentMethods} + profile={profile} /> diff --git a/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.test.tsx b/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.test.tsx index 653217ba7a1..836d413eef6 100644 --- a/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.test.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.test.tsx @@ -1,13 +1,18 @@ import * as React from 'react'; +import { profileFactory } from 'src/factories'; import { grantsFactory } from 'src/factories/grants'; -import { accountUserFactory } from 'src/factories/accountUsers'; import { renderWithTheme } from 'src/utilities/testHelpers'; import ContactInformation from './ContactInformation'; const EDIT_BUTTON_ID = 'edit-contact-info'; +const queryMocks = vi.hoisted(() => ({ + useGrants: vi.fn().mockReturnValue({}), + useProfile: vi.fn().mockReturnValue({}), +})); + const props = { address1: '123 Linode Lane', address2: '', @@ -18,21 +23,17 @@ const props = { firstName: 'Linny', lastName: 'The Platypus', phone: '19005553221', + profile: queryMocks.useProfile().data, state: 'PA', taxId: '1337', zip: '19106', }; -const queryMocks = vi.hoisted(() => ({ - useAccountUser: vi.fn().mockReturnValue({}), - useGrants: vi.fn().mockReturnValue({}), -})); - -vi.mock('src/queries/accountUsers', async () => { - const actual = await vi.importActual('src/queries/accountUsers'); +vi.mock('src/queries/profile', async () => { + const actual = await vi.importActual('src/queries/profile'); return { ...actual, - useAccountUser: queryMocks.useAccountUser, + useProfile: queryMocks.useProfile, }; }); @@ -44,19 +45,20 @@ vi.mock('src/queries/profile', async () => { }; }); -queryMocks.useAccountUser.mockReturnValue({ - data: accountUserFactory.build({ user_type: 'parent' }), -}); - describe('Edit Contact Information', () => { it('should be disabled for all child users', () => { - queryMocks.useAccountUser.mockReturnValue({ - data: accountUserFactory.build({ user_type: 'child' }), + queryMocks.useProfile.mockReturnValue({ + data: profileFactory.build({ + user_type: 'child', + }), }); - const { getByTestId } = renderWithTheme(, { - flags: { parentChildAccountAccess: true }, - }); + const { getByTestId } = renderWithTheme( + , + { + flags: { parentChildAccountAccess: true }, + } + ); expect(getByTestId(EDIT_BUTTON_ID)).toHaveAttribute( 'aria-disabled', diff --git a/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.tsx b/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.tsx index cff393293be..f762ae76668 100644 --- a/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.tsx @@ -8,8 +8,7 @@ import { Typography } from 'src/components/Typography'; import { getDisabledTooltipText } from 'src/features/Billing/billingUtils'; import { EDIT_BILLING_CONTACT } from 'src/features/Billing/constants'; import { useFlags } from 'src/hooks/useFlags'; -import { useAccountUser } from 'src/queries/accountUsers'; -import { useGrants, useProfile } from 'src/queries/profile'; +import { useGrants } from 'src/queries/profile'; import { BillingActionButton, @@ -18,6 +17,8 @@ import { } from '../../BillingDetail'; import BillingContactDrawer from './EditBillingContactDrawer'; +import type { Profile } from '@linode/api-v4'; + interface Props { address1: string; address2: string; @@ -28,6 +29,7 @@ interface Props { firstName: string; lastName: string; phone: string; + profile: Profile | undefined; state: string; taxId: string; zip: string; @@ -57,6 +59,7 @@ const ContactInformation = (props: Props) => { firstName, lastName, phone, + profile, state, taxId, zip, @@ -75,11 +78,10 @@ const ContactInformation = (props: Props) => { const [focusEmail, setFocusEmail] = React.useState(false); const flags = useFlags(); - const { data: profile } = useProfile(); const { data: grants } = useGrants(); - const { data: user } = useAccountUser(profile?.username ?? ''); + const isChildUser = - flags.parentChildAccountAccess && user?.user_type === 'child'; + flags.parentChildAccountAccess && profile?.user_type === 'child'; const isRestrictedUser = isChildUser || grants?.global.account_access === 'read_only'; diff --git a/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentInformation.test.tsx b/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentInformation.test.tsx index 0bfb0ff1954..19b70492b68 100644 --- a/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentInformation.test.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentInformation.test.tsx @@ -3,9 +3,8 @@ import { fireEvent } from '@testing-library/react'; import * as React from 'react'; import { PAYPAL_CLIENT_ID } from 'src/constants'; -import { paymentMethodFactory } from 'src/factories'; import { profileFactory } from 'src/factories'; -import { accountUserFactory } from 'src/factories/accountUsers'; +import { paymentMethodFactory } from 'src/factories'; import { grantsFactory } from 'src/factories/grants'; import { renderWithTheme, wrapWithTheme } from 'src/utilities/testHelpers'; @@ -22,32 +21,18 @@ vi.mock('@linode/api-v4/lib/account', async () => { }); const queryMocks = vi.hoisted(() => ({ - useAccountUser: vi.fn().mockReturnValue({}), useGrants: vi.fn().mockReturnValue({}), useProfile: vi.fn().mockReturnValue({}), })); -vi.mock('src/queries/accountUsers', async () => { - const actual = await vi.importActual('src/queries/accountUsers'); - return { - ...actual, - useAccountUser: queryMocks.useAccountUser, - }; -}); - vi.mock('src/queries/profile', async () => { const actual = await vi.importActual('src/queries/profile'); return { ...actual, useGrants: queryMocks.useGrants, - useProfile: queryMocks.useAccountUser, }; }); -queryMocks.useAccountUser.mockReturnValue({ - data: accountUserFactory.build({ user_type: 'parent' }), -}); - /* * Build payment method list that includes 1 valid and default payment method, * 2 valid non-default payment methods, and 1 expired payment method. @@ -64,15 +49,18 @@ const paymentMethods = [ }), ]; +const props = { + isAkamaiCustomer: false, + loading: false, + paymentMethods, + profile: queryMocks.useProfile().data, +}; + describe('Payment Info Panel', () => { it('Shows loading animation when loading', () => { const { getByLabelText } = renderWithTheme( - + ); @@ -82,11 +70,7 @@ describe('Payment Info Panel', () => { it('Shows Add Payment button for Linode customers and hides it for Akamai customers', () => { const { getByTestId, queryByText, rerender } = renderWithTheme( - + ); @@ -95,11 +79,7 @@ describe('Payment Info Panel', () => { rerender( wrapWithTheme( - + ) ); @@ -110,11 +90,7 @@ describe('Payment Info Panel', () => { it('Opens "Add Payment Method" drawer when "Add Payment Method" is clicked', () => { const { getByTestId } = renderWithTheme( - + ); @@ -127,11 +103,7 @@ describe('Payment Info Panel', () => { it('Lists all payment methods for Linode customers', () => { const { getByTestId } = renderWithTheme( - + ); @@ -145,11 +117,7 @@ describe('Payment Info Panel', () => { it('Hides payment methods and shows text for Akamai customers', () => { const { getByTestId, queryByTestId } = renderWithTheme( - + ); @@ -166,19 +134,15 @@ describe('Payment Info Panel', () => { queryMocks.useProfile.mockReturnValue({ data: profileFactory.build({ restricted: false, + user_type: 'child', }), }); - queryMocks.useAccountUser.mockReturnValue({ - data: accountUserFactory.build({ user_type: 'child' }), - }); - const { getByTestId } = renderWithTheme( , { @@ -203,11 +167,7 @@ describe('Payment Info Panel', () => { const { getByTestId } = renderWithTheme( - + ); diff --git a/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentInformation.tsx b/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentInformation.tsx index edfdb3a76cd..fdfc2a6aec6 100644 --- a/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentInformation.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentInformation.tsx @@ -12,8 +12,7 @@ import { getDisabledTooltipText } from 'src/features/Billing/billingUtils'; import { ADD_PAYMENT_METHOD } from 'src/features/Billing/constants'; import { useFlags } from 'src/hooks/useFlags'; import { queryKey } from 'src/queries/accountPayment'; -import { useAccountUser } from 'src/queries/accountUsers'; -import { useGrants, useProfile } from 'src/queries/profile'; +import { useGrants } from 'src/queries/profile'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { @@ -23,15 +22,18 @@ import { } from '../../BillingDetail'; import AddPaymentMethodDrawer from './AddPaymentMethodDrawer'; +import type { Profile } from '@linode/api-v4'; + interface Props { error?: APIError[] | null; isAkamaiCustomer: boolean; loading: boolean; paymentMethods: PaymentMethod[] | undefined; + profile: Profile | undefined; } const PaymentInformation = (props: Props) => { - const { error, isAkamaiCustomer, loading, paymentMethods } = props; + const { error, isAkamaiCustomer, loading, paymentMethods, profile } = props; const [addDrawerOpen, setAddDrawerOpen] = React.useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = React.useState( @@ -46,14 +48,13 @@ const PaymentInformation = (props: Props) => { const { replace } = useHistory(); const queryClient = useQueryClient(); const flags = useFlags(); - const { data: profile } = useProfile(); - const { data: user } = useAccountUser(profile?.username ?? ''); const { data: grants } = useGrants(); const drawerLink = '/account/billing/add-payment-method'; const addPaymentMethodRouteMatch = Boolean(useRouteMatch(drawerLink)); const isChildUser = - flags.parentChildAccountAccess && user?.user_type === 'child'; + flags.parentChildAccountAccess && profile?.user_type === 'child'; + const isRestrictedUser = isChildUser || grants?.global.account_access === 'read_only'; diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 4a29c8afc2e..c0007de8687 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -550,6 +550,8 @@ export const handlers = [ rest.get('*/profile', (req, res, ctx) => { const profile = profileFactory.build({ restricted: false, + // Parent/Child: switch the `user_type` depending on what account view you need to mock. + user_type: 'parent', }); return res(ctx.json(profile)); }), From 155e43c57f0111114625c8ccd3bedd457f4b5def Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Mon, 22 Jan 2024 08:57:35 -0500 Subject: [PATCH 04/44] upcoming: [M3-7599] - Update AGLB Configuration Ports Copy (#10079) * update copy * update error logic * remove unrelated change * Added changeset: Update AGLB Configuration Port Copy * add logic to change port based on protocol * add logic to change port based on protocol * Update packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Configurations/constants.tsx --------- Co-authored-by: Banks Nussman --- ...r-10079-upcoming-features-1705615366001.md | 5 ++ .../Configurations/ConfigurationForm.tsx | 46 +++++++++++++++++-- .../Configurations/constants.tsx | 4 +- 3 files changed, 49 insertions(+), 6 deletions(-) create mode 100644 packages/manager/.changeset/pr-10079-upcoming-features-1705615366001.md diff --git a/packages/manager/.changeset/pr-10079-upcoming-features-1705615366001.md b/packages/manager/.changeset/pr-10079-upcoming-features-1705615366001.md new file mode 100644 index 00000000000..bb9b4ff6328 --- /dev/null +++ b/packages/manager/.changeset/pr-10079-upcoming-features-1705615366001.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Update AGLB Configuration Port Copy ([#10079](https://github.com/linode/manager/pull/10079)) diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Configurations/ConfigurationForm.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Configurations/ConfigurationForm.tsx index 843b6e4e3f4..be21628f59f 100644 --- a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Configurations/ConfigurationForm.tsx +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Configurations/ConfigurationForm.tsx @@ -38,6 +38,7 @@ import type { CertificateConfig, Configuration, ConfigurationPayload, + Protocol, } from '@linode/api-v4'; interface EditProps { @@ -65,6 +66,8 @@ export const ConfigurationForm = (props: CreateProps | EditProps) => { const [isAddRouteDrawerOpen, setIsAddRouteDrawerOpen] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [hasChangedPort, setHasChangedPort] = useState(false); + const [routesTableQuery, setRoutesTableQuery] = useState(''); const loadbalancerId = Number(_loadbalancerId); @@ -148,9 +151,37 @@ export const ConfigurationForm = (props: CreateProps | EditProps) => { const handleReset = () => { formik.resetForm(); + setHasChangedPort(false); reset(); }; + const handleProtocolChange = (protocol: Protocol) => { + // If the user has changed the port manually, just update the protocol and NOT the port. + if (hasChangedPort) { + return formik.setFieldValue('protocol', protocol); + } + + let newPort = formik.values.port; + + if (protocol === 'http') { + newPort = 80; + } + + if (protocol === 'https') { + newPort = 443; + } + + // Update the protocol and port at the same time if the port is unchanged. + formik.setFormikState((prev) => ({ + ...prev, + values: { + ...prev.values, + port: newPort, + protocol, + }, + })); + }; + const generalErrors = error?.reduce((acc, { field, reason }) => { if ( !field || @@ -173,15 +204,19 @@ export const ConfigurationForm = (props: CreateProps | EditProps) => { /> )} { + handleProtocolChange(value); + }} textFieldProps={{ labelTooltipText: CONFIGURATION_COPY.Protocol, }} @@ -191,15 +226,18 @@ export const ConfigurationForm = (props: CreateProps | EditProps) => { disableClearable errorText={formik.errors.protocol} label="Protocol" - onChange={(e, { value }) => formik.setFieldValue('protocol', value)} options={protocolOptions} /> { + formik.handleChange(e); + setHasChangedPort(true); + }} + errorText={formik.touched.port ? formik.errors.port : undefined} label="Port" labelTooltipText={CONFIGURATION_COPY.Port} name="port" - onChange={formik.handleChange} + onBlur={formik.handleBlur} type="number" value={formik.values.port} /> diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Configurations/constants.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Configurations/constants.tsx index da980e603ef..6b32ca54c5c 100644 --- a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Configurations/constants.tsx +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Configurations/constants.tsx @@ -31,13 +31,13 @@ export const protocolOptions = [ { label: 'HTTPS', value: 'https' }, { label: 'HTTP', value: 'http' }, { label: 'TCP', value: 'tcp' }, -]; +] as const; export const CONFIGURATION_COPY = { Certificates: 'TLS termination certificates create an encrypted link between your clients and Global Load Balancer, and terminate incoming traffic on the load balancer. Once the load balancing policy is applied, traffic is forwarded to your service targets over encrypted TLS connections. Responses from your service targets to your clients are also encrypted.', Port: - 'The inbound port that the load balancer listens on. Enter 80 as the port number for HTTP, 443 for HTTPs, and 0-1023 for TCP.', + 'Set the inbound port value that the load balancer listens on to whichever port the client will connect to. The port can be 1-65535.', Protocol: ( Set to either TCP, HTTP, or HTTPS. See{' '} From 0fdf768d026d9bab03cba36bf1f71d459bdf9929 Mon Sep 17 00:00:00 2001 From: Dajahi Wiley <114682940+dwiley-akamai@users.noreply.github.com> Date: Mon, 22 Jan 2024 10:36:31 -0500 Subject: [PATCH 05/44] =?UTF-8?q?refactor:=20[M3-7672]=20=E2=80=93=20Use?= =?UTF-8?q?=20flags.vpc=20to=20determine=20if=20beta=20chip=20gets=20displ?= =?UTF-8?q?ayed=20in=20PrimaryNav=20(#10090)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/manager/src/components/PrimaryNav/PrimaryNav.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx index 8061c923d5a..d20995c1415 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx @@ -205,7 +205,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { hide: !showVPCs, href: '/vpcs', icon: , - isBeta: true, + isBeta: flags.vpc, // @TODO VPC: after VPC enters GA, remove this property entirely }, { display: 'Firewalls', From 4e7868076a8613d9b12ef25f4402af14a4d3e495 Mon Sep 17 00:00:00 2001 From: jdamore-linode <97627410+jdamore-linode@users.noreply.github.com> Date: Mon, 22 Jan 2024 13:03:07 -0500 Subject: [PATCH 06/44] test - Fix StackScript Test Failure due to Ubuntu 23.04 Image Deprecation (#10091) * Replace Ubuntu 23.04 with 23.10 when checking Image dropdown --- packages/manager/.changeset/pr-10091-tests-1705938529204.md | 5 +++++ .../e2e/core/stackscripts/create-stackscripts.spec.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 packages/manager/.changeset/pr-10091-tests-1705938529204.md diff --git a/packages/manager/.changeset/pr-10091-tests-1705938529204.md b/packages/manager/.changeset/pr-10091-tests-1705938529204.md new file mode 100644 index 00000000000..033c3de886c --- /dev/null +++ b/packages/manager/.changeset/pr-10091-tests-1705938529204.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Fix test failure related to Ubuntu 23.04 Image deprecation ([#10091](https://github.com/linode/manager/pull/10091)) diff --git a/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts index 58421d0ec17..052588d6282 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts @@ -300,7 +300,7 @@ describe('Create stackscripts', () => { { label: 'Debian 12', sel: 'linode/debian12' }, { label: 'Fedora 38', sel: 'linode/fedora38' }, { label: 'Rocky Linux 9', sel: 'linode/rocky9' }, - { label: 'Ubuntu 23.04', sel: 'linode/ubuntu23.04' }, + { label: 'Ubuntu 23.10', sel: 'linode/ubuntu23.10' }, ]; interceptCreateStackScript().as('createStackScript'); From f8452040a45e2490b3227b919e139f6383f57778 Mon Sep 17 00:00:00 2001 From: carrillo-erik <119514965+carrillo-erik@users.noreply.github.com> Date: Mon, 22 Jan 2024 12:40:37 -0800 Subject: [PATCH 07/44] upcoming: [M3-7608]: Placement Groups Landing page empty state (#10075) * upcoming: [M3-7608]: Placement Groups Landing page empty state * upcoming: [M3-7608]: Add changeset and remove unnecessary file * fix docs link unit test --- ...r-10075-upcoming-features-1705615244383.md | 5 +++ .../icons/entityIcons/placement-groups.svg | 3 +- .../PlacementGroupsLanding.test.tsx | 25 ++++++++++- .../PlacementGroupsLanding.tsx | 21 ++++++---- .../PlacementGroupsLandingEmptyState.tsx | 41 +++++++++++++++++++ .../PlacementGroupsLandingEmptyStateData.ts | 33 +++++++++++++++ .../src/features/PlacementGroups/constants.ts | 3 ++ 7 files changed, 119 insertions(+), 12 deletions(-) create mode 100644 packages/manager/.changeset/pr-10075-upcoming-features-1705615244383.md create mode 100644 packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLandingEmptyState.tsx create mode 100644 packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLandingEmptyStateData.ts diff --git a/packages/manager/.changeset/pr-10075-upcoming-features-1705615244383.md b/packages/manager/.changeset/pr-10075-upcoming-features-1705615244383.md new file mode 100644 index 00000000000..a31d1061c41 --- /dev/null +++ b/packages/manager/.changeset/pr-10075-upcoming-features-1705615244383.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Placement Groups Landing Page empty state ([#10075](https://github.com/linode/manager/pull/10075)) diff --git a/packages/manager/src/assets/icons/entityIcons/placement-groups.svg b/packages/manager/src/assets/icons/entityIcons/placement-groups.svg index 044e800bfb5..5ee28e5f41a 100644 --- a/packages/manager/src/assets/icons/entityIcons/placement-groups.svg +++ b/packages/manager/src/assets/icons/entityIcons/placement-groups.svg @@ -1,4 +1,5 @@ - + + diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.test.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.test.tsx index c8c69eb7ec5..6e579a4552f 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.test.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.test.tsx @@ -41,8 +41,12 @@ describe('PlacementGroupsLanding', () => { it('renders docs link and create button', () => { queryMocks.usePlacementGroupsQuery.mockReturnValue({ data: { - data: [], - results: 0, + data: [ + placementGroupFactory.build({ + label: 'group 1', + }), + ], + results: 1, }, }); @@ -72,4 +76,21 @@ describe('PlacementGroupsLanding', () => { expect(getByText(/group 1/i)).toBeInTheDocument(); expect(getByText(/group 2/i)).toBeInTheDocument(); }); + + it('should render placement group landing with empty state', () => { + queryMocks.usePlacementGroupsQuery.mockReturnValue({ + data: { + data: [], + results: 0, + }, + }); + + const { getByText } = renderWithTheme(); + + expect( + getByText( + 'Control the physical placement or distribution of virtual machines (VMs) instances within a data center or availability zone.' + ) + ).toBeInTheDocument(); + }); }); diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.tsx index fa3a6d542dd..0a9a78e4416 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.tsx @@ -23,6 +23,7 @@ import { usePlacementGroupsQuery } from 'src/queries/placementGroups'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { MAX_NUMBER_OF_PLACEMENT_GROUPS } from '../constants'; +import { PlacementGroupsLandingEmptyState } from './PlacementGroupsLandingEmptyState'; import { PlacementGroupsRow } from './PlacementGroupsRow'; import type { PlacementGroup } from '@linode/api-v4'; @@ -63,15 +64,21 @@ export const PlacementGroupsLanding = React.memo(() => { filter ); + const onOpenCreateDrawer = () => { + history.push('/placement-groups/create'); + }; + if (isLoading) { return ; } - // if (placementGroups?.results === 0) { - // return { - // /* TODO VM_Placement: add */ - // }; - // } + if (placementGroups?.results === 0) { + return ( + + ); + } if (error) { return ( @@ -84,10 +91,6 @@ export const PlacementGroupsLanding = React.memo(() => { ); } - const onOpenCreateDrawer = () => { - history.push('/placement-groups/create'); - }; - const handleRenamePlacementGroup = (placementGroup: PlacementGroup) => { setSelectedPlacementGroup(placementGroup); }; diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLandingEmptyState.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLandingEmptyState.tsx new file mode 100644 index 00000000000..a82fd0b40fb --- /dev/null +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLandingEmptyState.tsx @@ -0,0 +1,41 @@ +import * as React from 'react'; + +import PlacementGroups from 'src/assets/icons/entityIcons/placement-groups.svg'; +import { ResourcesSection } from 'src/components/EmptyLandingPageResources/ResourcesSection'; +import { sendEvent } from 'src/utilities/analytics'; + +import { + gettingStartedGuides, + headers, + linkAnalyticsEvent, +} from './PlacementGroupsLandingEmptyStateData'; + +interface Props { + openCreatePlacementGroupDrawer: () => void; +} + +export const PlacementGroupsLandingEmptyState = (props: Props) => { + const { openCreatePlacementGroupDrawer } = props; + + return ( + { + sendEvent({ + action: 'Click:button', + category: linkAnalyticsEvent.category, + label: 'Create Placement Group', + }); + openCreatePlacementGroupDrawer(); + }, + }, + ]} + gettingStartedGuidesData={gettingStartedGuides} + headers={headers} + icon={PlacementGroups} + linkAnalyticsEvent={linkAnalyticsEvent} + /> + ); +}; diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLandingEmptyStateData.ts b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLandingEmptyStateData.ts new file mode 100644 index 00000000000..4b0993874c3 --- /dev/null +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLandingEmptyStateData.ts @@ -0,0 +1,33 @@ +import { PLACEMENT_GROUP_LABEL } from 'src/features/PlacementGroups/constants'; + +import type { + ResourcesHeaders, + ResourcesLinkSection, + ResourcesLinks, +} from 'src/components/EmptyLandingPageResources/ResourcesLinksTypes'; + +export const headers: ResourcesHeaders = { + description: + 'Control the physical placement or distribution of virtual machines (VMs) instances within a data center or availability zone.', + subtitle: '', + title: PLACEMENT_GROUP_LABEL, +}; + +export const gettingStartedGuides: ResourcesLinkSection = { + links: [ + { + text: '', + to: '', + }, + ], + moreInfo: { + text: '', + to: '', + }, + title: '', +}; + +export const linkAnalyticsEvent: ResourcesLinks['linkAnalyticsEvent'] = { + action: 'Click:link', + category: 'Placement Groups landing page empty', +}; diff --git a/packages/manager/src/features/PlacementGroups/constants.ts b/packages/manager/src/features/PlacementGroups/constants.ts index 156376c24eb..ce8bc275987 100644 --- a/packages/manager/src/features/PlacementGroups/constants.ts +++ b/packages/manager/src/features/PlacementGroups/constants.ts @@ -1 +1,4 @@ +// Labels +export const PLACEMENT_GROUP_LABEL = 'Placement Groups'; + export const MAX_NUMBER_OF_PLACEMENT_GROUPS = 5; From 913141668518cf3348276e790f378702fb94f0b4 Mon Sep 17 00:00:00 2001 From: jdamore-linode <97627410+jdamore-linode@users.noreply.github.com> Date: Tue, 23 Jan 2024 09:14:27 -0500 Subject: [PATCH 08/44] test: Fix Domains landing page empty state test flake (#10094) * Fix flake by asserting element does not exist --- packages/manager/.changeset/pr-10094-tests-1705959873279.md | 5 +++++ .../e2e/core/domains/domains-empty-landing-page.spec.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 packages/manager/.changeset/pr-10094-tests-1705959873279.md diff --git a/packages/manager/.changeset/pr-10094-tests-1705959873279.md b/packages/manager/.changeset/pr-10094-tests-1705959873279.md new file mode 100644 index 00000000000..0c89d03a7f3 --- /dev/null +++ b/packages/manager/.changeset/pr-10094-tests-1705959873279.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Improve Domains landing page empty state test flakiness ([#10094](https://github.com/linode/manager/pull/10094)) diff --git a/packages/manager/cypress/e2e/core/domains/domains-empty-landing-page.spec.ts b/packages/manager/cypress/e2e/core/domains/domains-empty-landing-page.spec.ts index 6b99ded32a4..d702b520062 100644 --- a/packages/manager/cypress/e2e/core/domains/domains-empty-landing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/domains/domains-empty-landing-page.spec.ts @@ -60,7 +60,7 @@ describe('Domains empty landing page', () => { .should('be.enabled') .click(); }); - cy.findByText('Remote Nameserver').should('not.be.visible'); + cy.findByText('Remote Nameserver').should('not.exist'); // confirms clicking on 'Create Domain' button ui.button From 3c318362cf50f59066fc7ee416ff1bae5cd8f777 Mon Sep 17 00:00:00 2001 From: Jaalah Ramos <125309814+jaalah-akamai@users.noreply.github.com> Date: Tue, 23 Jan 2024 12:22:41 -0500 Subject: [PATCH 09/44] upcoming: [M3-7647] - Update logic for child_account PAT (#10083) Co-authored-by: Jaalah Ramos --- .../APITokens/CreateAPITokenDrawer.tsx | 34 +++-- .../APITokens/ViewAPITokenDrawer.test.tsx | 129 ++++++++++-------- .../Profile/APITokens/ViewAPITokenDrawer.tsx | 37 +++-- .../features/Profile/APITokens/utils.test.ts | 36 ++--- .../src/features/Profile/APITokens/utils.ts | 47 ++++--- 5 files changed, 145 insertions(+), 138 deletions(-) diff --git a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx index f066aecf0ef..b9bba270482 100644 --- a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx +++ b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx @@ -35,7 +35,7 @@ import { basePermNameMap as _basePermNameMap, Permission, allScopesAreTheSame, - getPermsNameMap, + filterPermsNameMap, permTuplesToScopeString, scopeStringToPermTuples, } from './utils'; @@ -113,12 +113,20 @@ export const CreateAPITokenDrawer = (props: Props) => { account?.capabilities ?? [] ); - // @TODO VPC: once VPC enters GA, remove _basePermNameMap logic and references. + const hasParentChildAccountAccess = Boolean(flags.parentChildAccountAccess); + + // @TODO: VPC & Parent/Child - once these are in GA, remove _basePermNameMap logic and references. // Just use the basePermNameMap import directly w/o any manipulation. - const basePermNameMap = getPermsNameMap(_basePermNameMap, { - name: 'vpc', - shouldBeIncluded: showVPCs, - }); + const basePermNameMap = filterPermsNameMap(_basePermNameMap, [ + { + name: 'vpc', + shouldBeIncluded: showVPCs, + }, + { + name: 'child_account', + shouldBeIncluded: hasParentChildAccountAccess, + }, + ]); const form = useFormik<{ expiry: string; @@ -180,24 +188,14 @@ export const CreateAPITokenDrawer = (props: Props) => { // Filter permissions for all users except parent user accounts. const allPermissions = form.values.scopes; + const showFilteredPermissions = (flags.parentChildAccountAccess && user?.user_type !== 'parent') || Boolean(!flags.parentChildAccountAccess); + const filteredPermissions = allPermissions.filter( (scopeTup) => basePermNameMap[scopeTup[0]] !== 'Child Account Access' ); - // TODO: Parent/Child - remove this conditional once code is in prod. - // Note: We couldn't include 'child_account' in our list of permissions in utils - // because it needs to be feature-flagged. Therefore, we're manually adding it here. - if (flags.parentChildAccountAccess && user?.user_type !== null) { - const childAccountIndex = allPermissions.findIndex( - ([scope]) => scope === 'child_account' - ); - if (childAccountIndex === -1) { - allPermissions.push(['child_account', 0]); - } - basePermNameMap.child_account = 'Child Account Access'; - } return ( diff --git a/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.test.tsx b/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.test.tsx index bdf1e47d887..474f0fe9c56 100644 --- a/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.test.tsx +++ b/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.test.tsx @@ -1,22 +1,24 @@ import * as React from 'react'; import { appTokenFactory } from 'src/factories'; -import { accountUserFactory } from 'src/factories/accountUsers'; +import { profileFactory } from 'src/factories'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { ViewAPITokenDrawer } from './ViewAPITokenDrawer'; import { basePerms } from './utils'; -// Mock the useAccountUser hooks to immediately return the expected data, circumventing the HTTP request and loading state. +import type { UserType } from '@linode/api-v4'; + +// Mock the useProfile hooks to immediately return the expected data, circumventing the HTTP request and loading state. const queryMocks = vi.hoisted(() => ({ - useAccountUser: vi.fn().mockReturnValue({}), + useProfile: vi.fn().mockReturnValue({}), })); -vi.mock('src/queries/accountUsers', async () => { - const actual = await vi.importActual('src/queries/accountUsers'); +vi.mock('src/queries/profile', async () => { + const actual = await vi.importActual('src/queries/profile'); return { ...actual, - useAccountUser: queryMocks.useAccountUser, + useProfile: queryMocks.useProfile, }; }); @@ -45,8 +47,13 @@ describe('View API Token Drawer', () => { }); it('should show all permissions as read/write with wildcard scopes', () => { + // We want to show all perms for this test, even perms specific to Parent/Child accounts. + queryMocks.useProfile.mockReturnValue({ + data: profileFactory.build({ user_type: 'parent' }), + }); + const { getByTestId } = renderWithTheme(, { - flags: { vpc: true }, + flags: { parentChildAccountAccess: true, vpc: true }, }); for (const permissionName of basePerms) { expect(getByTestId(`perm-${permissionName}`)).toHaveAttribute( @@ -57,9 +64,14 @@ describe('View API Token Drawer', () => { }); it('should show all permissions as none with no scopes', () => { + // We want to show all perms for this test, even perms specific to Parent/Child accounts. + queryMocks.useProfile.mockReturnValue({ + data: profileFactory.build({ user_type: 'parent' }), + }); + const { getByTestId } = renderWithTheme( , - { flags: { parentChildAccountAccess: false, vpc: true } } + { flags: { parentChildAccountAccess: true, vpc: true } } ); for (const permissionName of basePerms) { expect(getByTestId(`perm-${permissionName}`)).toHaveAttribute( @@ -70,12 +82,17 @@ describe('View API Token Drawer', () => { }); it('only account has read/write, all others are none', () => { + // We want to show all perms for this test, even perms specific to Parent/Child accounts. + queryMocks.useProfile.mockReturnValue({ + data: profileFactory.build({ user_type: 'parent' }), + }); + const { getByTestId } = renderWithTheme( , - { flags: { vpc: true } } + { flags: { parentChildAccountAccess: true, vpc: true } } ); for (const permissionName of basePerms) { // We only expect account to have read/write for this test @@ -88,19 +105,25 @@ describe('View API Token Drawer', () => { }); it('check table for more complex permissions', () => { + // We want to show all perms for this test, even perms specific to Parent/Child accounts. + queryMocks.useProfile.mockReturnValue({ + data: profileFactory.build({ user_type: 'parent' }), + }); + const { getByTestId } = renderWithTheme( , - { flags: { vpc: true } } + { flags: { parentChildAccountAccess: true, vpc: true } } ); const expectedScopeLevels = { account: 0, + child_account: 2, databases: 1, domains: 2, events: 2, @@ -126,50 +149,6 @@ describe('View API Token Drawer', () => { } }); - it('should show Child Account Access scope with read/write perms for a parent user account with the parent/child feature flag on', () => { - queryMocks.useAccountUser.mockReturnValue({ - data: accountUserFactory.build({ user_type: 'parent' }), - }); - - const { getByTestId, getByText } = renderWithTheme( - , - { - flags: { parentChildAccountAccess: true }, - } - ); - - const childScope = getByText('Child Account Access'); - // TODO: Parent/Child - confirm that this scope level shouldn't be 2 - const expectedScopeLevels = { - child_account: 0, - } as const; - const childPermissionName = 'child_account'; - - expect(childScope).toBeInTheDocument(); - expect(getByTestId(`perm-${childPermissionName}`)).toHaveAttribute( - ariaLabel, - `This token has ${expectedScopeLevels[childPermissionName]} access for ${childPermissionName}` - ); - }); - - it('should not show the Child Account Access scope for a non-parent user account with the parent/child feature flag on', () => { - queryMocks.useAccountUser.mockReturnValue({ - data: accountUserFactory.build({ user_type: null }), - }); - - const { queryByText } = renderWithTheme(, { - flags: { parentChildAccountAccess: true }, - }); - - const childScope = queryByText('Child Account Access'); - expect(childScope).not.toBeInTheDocument(); - }); - it('Should show the VPC scope with the VPC feature flag on', () => { const { getByText } = renderWithTheme(, { flags: { vpc: true }, @@ -186,4 +165,44 @@ describe('View API Token Drawer', () => { const vpcScope = queryByText('VPCs'); expect(vpcScope).not.toBeInTheDocument(); }); + + describe('Parent/Child: User Roles', () => { + const setupAndRender = ( + userType: UserType | null, + enableFeatureFlag = true + ) => { + queryMocks.useProfile.mockReturnValue({ + data: profileFactory.build({ user_type: userType }), + }); + + return renderWithTheme(, { + flags: { parentChildAccountAccess: enableFeatureFlag }, + }); + }; + + const testChildScopeNotDisplayed = ( + userType: UserType | null, + enableFeatureFlag = true + ) => { + const { queryByText } = setupAndRender(userType, enableFeatureFlag); + const childScope = queryByText('Child Account Access'); + expect(childScope).not.toBeInTheDocument(); + }; + + it('should not display the Child Account Access when feature flag is disabled', () => { + testChildScopeNotDisplayed('parent', false); + }); + + it('should not display the Child Account Access scope for a user account without a parent uer type', () => { + testChildScopeNotDisplayed(null); + }); + + it('should not display the Child Account Access scope for "proxy" user type', () => { + testChildScopeNotDisplayed('proxy'); + }); + + it('should not display the Child Account Access scope for "child" user type', () => { + testChildScopeNotDisplayed('child'); + }); + }); }); diff --git a/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.tsx b/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.tsx index 30f13459424..aede53c4aca 100644 --- a/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.tsx +++ b/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.tsx @@ -9,7 +9,6 @@ import { TableRow } from 'src/components/TableRow'; import { AccessCell } from 'src/features/ObjectStorage/AccessKeyLanding/AccessCell'; import { useFlags } from 'src/hooks/useFlags'; import { useAccount } from 'src/queries/account'; -import { useAccountUser } from 'src/queries/accountUsers'; import { useProfile } from 'src/queries/profile'; import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; @@ -20,7 +19,7 @@ import { } from './APITokenDrawer.styles'; import { basePermNameMap as _basePermNameMap, - getPermsNameMap, + filterPermsNameMap, scopeStringToPermTuples, } from './utils'; @@ -37,7 +36,6 @@ export const ViewAPITokenDrawer = (props: Props) => { const { data: profile } = useProfile(); const { data: account } = useAccount(); - const { data: user } = useAccountUser(profile?.username ?? ''); const showVPCs = isFeatureEnabled( 'VPCs', @@ -45,34 +43,31 @@ export const ViewAPITokenDrawer = (props: Props) => { account?.capabilities ?? [] ); - // @TODO VPC: once VPC enters GA, remove _basePermNameMap logic and references. + const hasParentChildAccountAccess = Boolean(flags.parentChildAccountAccess); + + // @TODO: VPC & Parent/Child - once these are in GA, remove _basePermNameMap logic and references. // Just use the basePermNameMap import directly w/o any manipulation. - const basePermNameMap = getPermsNameMap(_basePermNameMap, { - name: 'vpc', - shouldBeIncluded: showVPCs, - }); + const basePermNameMap = filterPermsNameMap(_basePermNameMap, [ + { + name: 'vpc', + shouldBeIncluded: showVPCs, + }, + { + name: 'child_account', + shouldBeIncluded: hasParentChildAccountAccess, + }, + ]); const allPermissions = scopeStringToPermTuples(token?.scopes ?? ''); // Filter permissions for all users except parent user accounts. const showFilteredPermissions = - (flags.parentChildAccountAccess && user?.user_type !== 'parent') || + (flags.parentChildAccountAccess && profile?.user_type !== 'parent') || Boolean(!flags.parentChildAccountAccess); + const filteredPermissions = allPermissions.filter( (scopeTup) => basePermNameMap[scopeTup[0]] !== 'Child Account Access' ); - // TODO: Parent/Child - remove this conditional once code is in prod. - // Note: We couldn't include 'child_account' in our list of permissions in utils - // because it needs to be feature-flagged. Therefore, we're manually adding it here. - if (flags.parentChildAccountAccess && user?.user_type !== null) { - const childAccountIndex = allPermissions.findIndex( - ([scope]) => scope === 'child_account' - ); - if (childAccountIndex === -1) { - allPermissions.push(['child_account', 0]); - } - basePermNameMap.child_account = 'Child Account Access'; - } return ( diff --git a/packages/manager/src/features/Profile/APITokens/utils.test.ts b/packages/manager/src/features/Profile/APITokens/utils.test.ts index 3e2a78874a8..c8e19673a99 100644 --- a/packages/manager/src/features/Profile/APITokens/utils.test.ts +++ b/packages/manager/src/features/Profile/APITokens/utils.test.ts @@ -26,8 +26,7 @@ describe('APIToken utils', () => { const result = scopeStringToPermTuples('*'); const expected = [ ['account', 2], - // TODO: Parent/Child - add this scope once code is in prod. - // ['child_account', 2], + ['child_account', 2], ['databases', 2], ['domains', 2], ['events', 2], @@ -52,8 +51,7 @@ describe('APIToken utils', () => { const result = scopeStringToPermTuples(''); const expected = [ ['account', 0], - // TODO: Parent/Child - add this scope once code is in prod. - // ['child_account', 0], + ['child_account', 0], ['databases', 0], ['domains', 0], ['events', 0], @@ -79,8 +77,7 @@ describe('APIToken utils', () => { const result = scopeStringToPermTuples('account:none'); const expected = [ ['account', 0], - // TODO: Parent/Child - add this scope once code is in prod. - // ['child_account', 0], + ['child_account', 0], ['databases', 0], ['domains', 0], ['events', 0], @@ -106,8 +103,7 @@ describe('APIToken utils', () => { const result = scopeStringToPermTuples('account:read_only'); const expected = [ ['account', 1], - // TODO: Parent/Child - add this scope once code is in prod. - // ['child_account', 0], + ['child_account', 0], ['databases', 0], ['domains', 0], ['events', 0], @@ -133,8 +129,7 @@ describe('APIToken utils', () => { const result = scopeStringToPermTuples('account:read_write'); const expected = [ ['account', 2], - // TODO: Parent/Child - add this scope once code is in prod. - // ['child_account', 0], + ['child_account', 0], ['databases', 0], ['domains', 0], ['events', 0], @@ -162,8 +157,7 @@ describe('APIToken utils', () => { ); const expected = [ ['account', 0], - // TODO: Parent/Child - add this scope once code is in prod. - // ['child_account', 0], + ['child_account', 0], ['databases', 0], ['domains', 1], ['events', 0], @@ -193,8 +187,7 @@ describe('APIToken utils', () => { const result = scopeStringToPermTuples('account:none,tokens:read_write'); const expected = [ ['account', 2], - // TODO: Parent/Child - add this scope once code is in prod. - // ['child_account', 0], + ['child_account', 0], ['databases', 0], ['domains', 0], ['events', 0], @@ -224,8 +217,7 @@ describe('APIToken utils', () => { const result = scopeStringToPermTuples('account:read_only,tokens:none'); const expected = [ ['account', 1], - // TODO: Parent/Child - add this scope once code is in prod. - // ['child_account', 0], + ['child_account', 0], ['databases', 0], ['domains', 0], ['events', 0], @@ -251,8 +243,7 @@ describe('APIToken utils', () => { it('should return 0 if all scopes are 0', () => { const scopes: Permission[] = [ ['account', 0], - // TODO: Parent/Child - add this scope once code is in prod. - // ['child_account', 0], + ['child_account', 0], ['databases', 0], ['domains', 0], ['events', 0], @@ -273,8 +264,7 @@ describe('APIToken utils', () => { it('should return 1 if all scopes are 1', () => { const scopes: Permission[] = [ ['account', 1], - // TODO: Parent/Child - add this scope once code is in prod. - // ['child_account', 1], + ['child_account', 1], ['databases', 1], ['domains', 1], ['events', 1], @@ -294,8 +284,7 @@ describe('APIToken utils', () => { it('should return 2 if all scopes are 2', () => { const scopes: Permission[] = [ ['account', 2], - // TODO: Parent/Child - add this scope once code is in prod. - // ['child_account', 2], + ['child_account', 2], ['databases', 2], ['domains', 2], ['events', 2], @@ -316,8 +305,7 @@ describe('APIToken utils', () => { it('should return null if all scopes are different', () => { const scopes: Permission[] = [ ['account', 1], - // TODO: Parent/Child - add this scope once code is in prod. - // ['child_account', 0], + ['child_account', 0], ['databases', 0], ['domains', 2], ['events', 0], diff --git a/packages/manager/src/features/Profile/APITokens/utils.ts b/packages/manager/src/features/Profile/APITokens/utils.ts index c36f1db6955..67da585fe32 100644 --- a/packages/manager/src/features/Profile/APITokens/utils.ts +++ b/packages/manager/src/features/Profile/APITokens/utils.ts @@ -6,8 +6,7 @@ export type Permission = [string, number]; export const basePerms = [ 'account', - // TODO: Parent/Child - add this scope once API code is in prod. - // 'child_account', + 'child_account', 'databases', 'domains', 'events', @@ -24,10 +23,9 @@ export const basePerms = [ 'vpc', ] as const; -export const basePermNameMap: Record = { +export const basePermNameMap = { account: 'Account', - // TODO: Parent/Child - add this scope once API code is in prod. - // child_account: 'Child Account Access', + child_account: 'Child Account Access', databases: 'Databases', domains: 'Domains', events: 'Events', @@ -42,7 +40,7 @@ export const basePermNameMap: Record = { stackscripts: 'StackScripts', volumes: 'Volumes', vpc: 'VPCs', -}; +} as const; export const inverseLevelMap = ['none', 'read_only', 'read_write']; @@ -197,21 +195,30 @@ export const isWayInTheFuture = (time: string) => { }; /** - * Used to remove a permission - * @param basePermNameMap an object consisting of API perm keys and their - * corresponding names in Cloud - * @param perm an object consisting of a perm name and a boolean indicating - * whether it should be included in basePermNameMap or not - * @returns a copy of basePermNameMap (either unedited or with the specified perm removed) + * Filters permissions from a base map, removing those specified in the perm parameter. + * + * @param basePermNameMap - Map of API permission keys to their corresponding Cloud names. + * @param perm - Array of objects specifying permissions for inclusion or exclusion: + * - name: Key of the permission to filter. + * - shouldBeIncluded: Boolean indicating whether to include or exclude the permission. + * + * @returns A new map containing only the allowed permissions from basePermNameMap. */ -export const getPermsNameMap = ( - basePermNameMap: Record, - perm: { name: string; shouldBeIncluded: boolean } -) => { - const basePermNameMapCopy = { ...basePermNameMap }; - if (basePermNameMapCopy[perm.name] && !perm.shouldBeIncluded) { - delete basePermNameMapCopy[perm.name]; +export const filterPermsNameMap = < + // We're constraining T to an array of objects with the following shape: + T extends { name: keyof typeof basePermNameMap; shouldBeIncluded: boolean }[] +>( + permMap: typeof basePermNameMap, + perm: T +): // Return type excludes the keys specified by T in the perm parameter dynamically. +Omit => { + const filteredPermNameMap = { ...permMap }; + + for (const { name, shouldBeIncluded } of perm) { + if (!shouldBeIncluded && filteredPermNameMap[name]) { + delete filteredPermNameMap[name]; + } } - return basePermNameMapCopy; + return filteredPermNameMap; }; From b84275a4c8d048183a8eada9ab245c55efc5bbca Mon Sep 17 00:00:00 2001 From: Jaalah Ramos <125309814+jaalah-akamai@users.noreply.github.com> Date: Tue, 23 Jan 2024 12:23:50 -0500 Subject: [PATCH 10/44] upcoming: [M3-7430] - Implement Account Switching Functionality (#10064) Co-authored-by: Jaalah Ramos Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> --- ...r-10064-upcoming-features-1705092306140.md | 5 + .../src/assets/icons/error-state-cloud.svg | 10 + .../src/features/Account/AccountLanding.tsx | 2 +- .../Account/SwitchAccountButton.test.tsx | 25 ++ .../features/Account/SwitchAccountButton.tsx | 2 +- .../Account/SwitchAccountDrawer.test.tsx | 33 +-- .../features/Account/SwitchAccountDrawer.tsx | 241 +++++++++++++----- .../SwitchAccounts/ChildAccountList.test.tsx | 45 ++++ .../SwitchAccounts/ChildAccountList.tsx | 122 +++++++++ .../manager/src/features/Account/utils.ts | 78 ++++++ .../features/TopMenu/UserMenu/UserMenu.tsx | 155 ++++++----- 11 files changed, 533 insertions(+), 185 deletions(-) create mode 100644 packages/manager/.changeset/pr-10064-upcoming-features-1705092306140.md create mode 100644 packages/manager/src/assets/icons/error-state-cloud.svg create mode 100644 packages/manager/src/features/Account/SwitchAccountButton.test.tsx create mode 100644 packages/manager/src/features/Account/SwitchAccounts/ChildAccountList.test.tsx create mode 100644 packages/manager/src/features/Account/SwitchAccounts/ChildAccountList.tsx create mode 100644 packages/manager/src/features/Account/utils.ts diff --git a/packages/manager/.changeset/pr-10064-upcoming-features-1705092306140.md b/packages/manager/.changeset/pr-10064-upcoming-features-1705092306140.md new file mode 100644 index 00000000000..2e1fd3f253c --- /dev/null +++ b/packages/manager/.changeset/pr-10064-upcoming-features-1705092306140.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Implement Account Switching Functionality ([#10064](https://github.com/linode/manager/pull/10064)) diff --git a/packages/manager/src/assets/icons/error-state-cloud.svg b/packages/manager/src/assets/icons/error-state-cloud.svg new file mode 100644 index 00000000000..43770d38e10 --- /dev/null +++ b/packages/manager/src/assets/icons/error-state-cloud.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/manager/src/features/Account/AccountLanding.tsx b/packages/manager/src/features/Account/AccountLanding.tsx index 68b0e028020..cc5b5007f3b 100644 --- a/packages/manager/src/features/Account/AccountLanding.tsx +++ b/packages/manager/src/features/Account/AccountLanding.tsx @@ -174,9 +174,9 @@ const AccountLanding = () => {
setIsDrawerOpen(false)} open={isDrawerOpen} - username={user?.username ?? ''} /> ); diff --git a/packages/manager/src/features/Account/SwitchAccountButton.test.tsx b/packages/manager/src/features/Account/SwitchAccountButton.test.tsx new file mode 100644 index 00000000000..c991d2c2357 --- /dev/null +++ b/packages/manager/src/features/Account/SwitchAccountButton.test.tsx @@ -0,0 +1,25 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { SwitchAccountButton } from 'src/features/Account/SwitchAccountButton'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +describe('SwitchAccountButton', () => { + test('renders Switch Account button with SwapIcon', () => { + renderWithTheme(); + + expect(screen.getByText('Switch Account')).toBeInTheDocument(); + + expect(screen.getByTestId('swap-icon')).toBeInTheDocument(); + }); + + test('calls onClick handler when button is clicked', () => { + const onClickMock = vi.fn(); + renderWithTheme(); + + userEvent.click(screen.getByText('Switch Account')); + + expect(onClickMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/manager/src/features/Account/SwitchAccountButton.tsx b/packages/manager/src/features/Account/SwitchAccountButton.tsx index fd9f4966c9b..9a2b9bfb960 100644 --- a/packages/manager/src/features/Account/SwitchAccountButton.tsx +++ b/packages/manager/src/features/Account/SwitchAccountButton.tsx @@ -5,7 +5,7 @@ import { Button, ButtonProps } from 'src/components/Button/Button'; export const SwitchAccountButton = (props: ButtonProps) => { return ( - ); diff --git a/packages/manager/src/features/Account/SwitchAccountDrawer.test.tsx b/packages/manager/src/features/Account/SwitchAccountDrawer.test.tsx index ad938a6cd72..399872c11da 100644 --- a/packages/manager/src/features/Account/SwitchAccountDrawer.test.tsx +++ b/packages/manager/src/features/Account/SwitchAccountDrawer.test.tsx @@ -1,15 +1,14 @@ -import { fireEvent, within } from '@testing-library/react'; +import { fireEvent } from '@testing-library/react'; import * as React from 'react'; -import { accountFactory } from 'src/factories/account'; import { accountUserFactory } from 'src/factories/accountUsers'; -import { makeResourcePage } from 'src/mocks/serverHandlers'; import { rest, server } from 'src/mocks/testServer'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { SwitchAccountDrawer } from './SwitchAccountDrawer'; const props = { + isProxyUser: false, onClose: vi.fn(), open: true, username: 'mock-user', @@ -39,7 +38,7 @@ describe('SwitchAccountDrawer', () => { ); const { findByLabelText, getByText } = renderWithTheme( - + ); expect( @@ -53,32 +52,6 @@ describe('SwitchAccountDrawer', () => { ); }); - it('should display a list of child accounts', async () => { - server.use( - rest.get('*/account/users/*', (req, res, ctx) => { - return res(ctx.json(accountUserFactory.build({ user_type: 'parent' }))); - }), - rest.get('*/account/child-accounts', (req, res, ctx) => { - return res( - ctx.json( - makeResourcePage( - accountFactory.buildList(5, { company: 'Child Co.' }) - ) - ) - ); - }) - ); - - const { findByTestId } = renderWithTheme( - - ); - - const childAccounts = await findByTestId('child-account-list'); - expect( - within(childAccounts).getAllByText('Child Co.', { exact: false }) - ).toHaveLength(5); - }); - it('should close when the close icon is clicked', () => { const { getByLabelText } = renderWithTheme( diff --git a/packages/manager/src/features/Account/SwitchAccountDrawer.tsx b/packages/manager/src/features/Account/SwitchAccountDrawer.tsx index 830391bd0a4..987bff1687b 100644 --- a/packages/manager/src/features/Account/SwitchAccountDrawer.tsx +++ b/packages/manager/src/features/Account/SwitchAccountDrawer.tsx @@ -1,104 +1,207 @@ -import { Typography, styled } from '@mui/material'; -import { AxiosHeaders } from 'axios'; +import { createChildAccountPersonalAccessToken } from '@linode/api-v4'; import React from 'react'; +import { useHistory } from 'react-router-dom'; import { StyledLinkButton } from 'src/components/Button/StyledLinkButton'; -import { CircleProgress } from 'src/components/CircleProgress'; import { Drawer } from 'src/components/Drawer'; import { Notice } from 'src/components/Notice/Notice'; -import { Stack } from 'src/components/Stack'; -import { useFlags } from 'src/hooks/useFlags'; -import { useChildAccounts } from 'src/queries/account'; -import { useAccountUser } from 'src/queries/accountUsers'; -import { useProfile } from 'src/queries/profile'; -import { authentication } from 'src/utilities/storage'; +import { Typography } from 'src/components/Typography'; +import { + isParentTokenValid, + setTokenInLocalStorage, + updateCurrentTokenBasedOnUserType, +} from 'src/features/Account/utils'; +import { useCurrentToken } from 'src/hooks/useAuthentication'; +import { getStorage } from 'src/utilities/storage'; + +import { ChildAccountList } from './SwitchAccounts/ChildAccountList'; + +import type { APIError, ChildAccountPayload, UserType } from '@linode/api-v4'; +import type { State as AuthState } from 'src/store/authentication'; interface Props { + isProxyUser: boolean; onClose: () => void; open: boolean; - username: string; } export const SwitchAccountDrawer = (props: Props) => { - const { onClose, open } = props; + const { isProxyUser, onClose, open } = props; + + const [isParentTokenError, setIsParentTokenError] = React.useState< + APIError[] + >([]); + const [isProxyTokenError, setIsProxyTokenError] = React.useState( + [] + ); - const flags = useFlags(); + const currentTokenWithBearer = useCurrentToken() ?? ''; + const history = useHistory(); - const handleClose = () => { + const handleClose = React.useCallback(() => { onClose(); - }; - - const { data: profile } = useProfile(); - const { data: user } = useAccountUser(profile?.username ?? ''); - - // From proxy accounts, make a request on behalf of the parent account to fetch child accounts. - const headers = - flags.parentChildAccountAccess && user?.user_type === 'proxy' - ? new AxiosHeaders({ Authorization: authentication.token.get() }) // TODO: Parent/Child - M3-7430: replace this token with the parent token in local storage. - : undefined; - const { data: childAccounts, error, isLoading } = useChildAccounts({ - headers, - }); - - const renderChildAccounts = React.useCallback(() => { - if (isLoading) { - return ; - } + }, [onClose]); - if (childAccounts?.results === 0) { - return There are no child accounts.; - } + /** + * Headers are required for proxy users when obtaining a proxy token. + * For 'proxy' userType, use the stored parent token in the request. + */ + const getProxyToken = React.useCallback( + async ({ + euuid, + token, + userType, + }: { + euuid: ChildAccountPayload['euuid']; + token: string; + userType: Omit; + }) => { + try { + return await createChildAccountPersonalAccessToken({ + euuid, + headers: + userType === 'proxy' + ? { + Authorization: `Bearer ${token}`, + } + : undefined, + }); + } catch (error) { + setIsProxyTokenError(error as APIError[]); + throw error; + } + }, + [] + ); + + // Navigate to the current location, triggering a re-render without a full page reload. + const refreshPage = React.useCallback(() => { + // TODO: Parent/Child: We need to test this against the real API. + history.push(history.location.pathname); + }, [history]); + + const handleSwitchToChildAccount = React.useCallback( + async ({ + currentTokenWithBearer, + euuid, + event, + handleClose, + isProxyUser, + }: { + currentTokenWithBearer?: AuthState['token']; + euuid: string; + event: React.MouseEvent; + handleClose: (e: React.SyntheticEvent) => void; + isProxyUser: boolean; + }) => { + try { + // TODO: Parent/Child: FOR MSW ONLY, REMOVE WHEN API IS READY + // ================================================================ + // throw new Error( + // `Account switching failed. Try again.` + // ); + // ================================================================ + + // We don't need to worry about this if we're a proxy user. + if (!isProxyUser) { + const parentToken = { + expiry: getStorage('authenication/expire'), + scopes: getStorage('authenication/scopes'), + token: currentTokenWithBearer ?? '', + }; + + setTokenInLocalStorage({ + prefix: 'authentication/parent_token', + token: parentToken, + }); + } + + const proxyToken = await getProxyToken({ + euuid, + token: isProxyUser + ? getStorage('authentication/parent_token/token') + : currentTokenWithBearer, + userType: isProxyUser ? 'proxy' : 'parent', + }); - if (error) { - return ( - - There was an error loading child accounts. - - ); + setTokenInLocalStorage({ + prefix: 'authentication/proxy_token', + token: proxyToken, + }); + + updateCurrentTokenBasedOnUserType({ + userType: 'proxy', + }); + + handleClose(event); + refreshPage(); + } catch (error) { + setIsProxyTokenError(error as APIError[]); + + // TODO: Parent/Child: FOR MSW ONLY, REMOVE WHEN API IS READY + // ================================================================ + // setIsProxyTokenError([ + // { + // field: 'token', + // reason: error.message, + // }, + // ]); + // ================================================================ + } + }, + [getProxyToken, refreshPage] + ); + + const handleSwitchToParentAccount = React.useCallback(() => { + if (!isParentTokenValid({ isProxyUser })) { + const expiredTokenError: APIError = { + field: 'token', + reason: + 'The reseller account token has expired. You must log back into the account manually.', + }; + + setIsParentTokenError([expiredTokenError]); + + return; } - return childAccounts?.data.map((childAccount, idx) => ( - { - // TODO: Parent/Child - M3-7430 - // handleAccountSwitch(); - }} - key={`child-account-link-button-${idx}`} - > - {childAccount.company} - - )); - }, [childAccounts, error, isLoading]); + updateCurrentTokenBasedOnUserType({ userType: 'parent' }); + handleClose(); + }, [handleClose, isProxyUser]); return ( - + {isProxyTokenError.length > 0 && ( + + )} + {isParentTokenError.length > 0 && ( + + )} + ({ + margin: `${theme.spacing(3)} 0`, + })} + > Select an account to view and manage its settings and configurations - {user?.user_type === 'proxy' && ( + {isProxyUser && ( <> - {' '} - or {/* TODO: Parent/Child - M3-7430 */} + {' or '} null} + onClick={handleSwitchToParentAccount} > switch back to your account )} . - - - {renderChildAccounts()} - + + ); }; - -const StyledTypography = styled(Typography)(({ theme }) => ({ - margin: `${theme.spacing(3)} 0`, -})); - -const StyledChildAccountLinkButton = styled(StyledLinkButton)(({ theme }) => ({ - marginBottom: theme.spacing(2), -})); diff --git a/packages/manager/src/features/Account/SwitchAccounts/ChildAccountList.test.tsx b/packages/manager/src/features/Account/SwitchAccounts/ChildAccountList.test.tsx new file mode 100644 index 00000000000..63dd01d786f --- /dev/null +++ b/packages/manager/src/features/Account/SwitchAccounts/ChildAccountList.test.tsx @@ -0,0 +1,45 @@ +import { waitFor, within } from '@testing-library/react'; +import * as React from 'react'; + +import { accountFactory } from 'src/factories/account'; +import { accountUserFactory } from 'src/factories/accountUsers'; +import { ChildAccountList } from 'src/features/Account/SwitchAccounts/ChildAccountList'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { rest, server } from 'src/mocks/testServer'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +const props = { + currentTokenWithBearer: 'Bearer 123', + isProxyUser: false, + onClose: vi.fn(), + onSwitchAccount: vi.fn(), +}; + +it('should display a list of child accounts', async () => { + server.use( + rest.get('*/account/users/*', (req, res, ctx) => { + return res(ctx.json(accountUserFactory.build({ user_type: 'parent' }))); + }), + rest.get('*/account/child-accounts', (req, res, ctx) => { + return res( + ctx.json( + makeResourcePage( + accountFactory.buildList(5, { company: 'Child Co.' }) + ) + ) + ); + }) + ); + + const { findByTestId } = renderWithTheme(); + + await waitFor(async () => { + expect(await findByTestId('child-account-list')).not.toBeNull(); + }); + + const childAccounts = await findByTestId('child-account-list'); + + expect( + within(childAccounts).getAllByText('Child Co.', { exact: false }) + ).toHaveLength(5); +}); diff --git a/packages/manager/src/features/Account/SwitchAccounts/ChildAccountList.tsx b/packages/manager/src/features/Account/SwitchAccounts/ChildAccountList.tsx new file mode 100644 index 00000000000..fb97a41862a --- /dev/null +++ b/packages/manager/src/features/Account/SwitchAccounts/ChildAccountList.tsx @@ -0,0 +1,122 @@ +import React from 'react'; +import { useQueryClient } from 'react-query'; + +import ErrorStateCloud from 'src/assets/icons/error-state-cloud.svg'; +import { Box } from 'src/components/Box'; +import { Button } from 'src/components/Button/Button'; +import { StyledLinkButton } from 'src/components/Button/StyledLinkButton'; +import { CircleProgress } from 'src/components/CircleProgress'; +import { Notice } from 'src/components/Notice/Notice'; +import { Stack } from 'src/components/Stack'; +import { Typography } from 'src/components/Typography'; +import { + queryKey as accountQueryKey, + useChildAccounts, +} from 'src/queries/account'; + +interface ChildAccountListProps { + currentTokenWithBearer: string; + isProxyUser: boolean; + onClose: () => void; + onSwitchAccount: (props: { + currentTokenWithBearer: string; + euuid: string; + event: React.MouseEvent; + handleClose: () => void; + isProxyUser: boolean; + }) => void; +} + +export const ChildAccountList = React.memo( + ({ + currentTokenWithBearer, + isProxyUser, + onClose, + onSwitchAccount, + }: ChildAccountListProps) => { + const { + data: childAccounts, + isError, + isLoading, + refetch: refetchChildAccounts, + } = useChildAccounts({ + headers: isProxyUser + ? { + Authorization: currentTokenWithBearer, + } + : undefined, + }); + const queryClient = useQueryClient(); + + if (isLoading) { + return ( + + + + ); + } + + if (childAccounts?.results === 0) { + return ( + There are no indirect customer accounts. + ); + } + + if (isError) { + return ( + + + Unable to load data. + + Try again or contact support if the issue persists. + + + + ); + } + + const renderChildAccounts = childAccounts?.data.map((childAccount, idx) => { + const euuid = childAccount.euuid; + return ( + + onSwitchAccount({ + currentTokenWithBearer, + euuid, + event, + handleClose: onClose, + isProxyUser, + }) + } + sx={(theme) => ({ + marginBottom: theme.spacing(2), + })} + key={`child-account-link-button-${idx}`} + > + {childAccount.company} + + ); + }); + + return ( + + {renderChildAccounts} + + ); + } +); diff --git a/packages/manager/src/features/Account/utils.ts b/packages/manager/src/features/Account/utils.ts new file mode 100644 index 00000000000..7d1052487f9 --- /dev/null +++ b/packages/manager/src/features/Account/utils.ts @@ -0,0 +1,78 @@ +import { getStorage, setStorage } from 'src/utilities/storage'; + +import type { Token } from '@linode/api-v4'; + +// TODO: Parent/Child: FOR MSW ONLY, REMOVE WHEN API IS READY +// ================================================================ +// const mockExpiredTime = +// 'Mon Nov 20 2023 22:50:52 GMT-0800 (Pacific Standard Time)'; +// ================================================================ + +/** + * Determine whether the tokens used for switchable accounts are still valid. + */ +export const isParentTokenValid = ({ + isProxyUser, +}: { + isProxyUser: boolean; +}) => { + const now = new Date().toISOString(); + + // From a proxy user, check whether parent token is still valid before switching. + if ( + isProxyUser && + now > + new Date(getStorage('authentication/parent_token/expire')).toISOString() + + // TODO: Parent/Child: FOR MSW ONLY, REMOVE WHEN API IS READY + // ================================================================ + // new Date(mockExpiredTime).toISOString() + // ================================================================ + ) { + return false; + } + return true; +}; + +/** + * Set token information in the local storage. + * This allows us to store a token for later use, such as switching between parent and proxy accounts. + */ +export const setTokenInLocalStorage = ({ + prefix, + token = { expiry: '', scopes: '', token: '' }, +}: { + prefix: string; + token?: Pick; +}) => { + const { expiry, scopes, token: tokenValue } = token; + + if (!tokenValue || !expiry) { + return; + } + + setStorage(`${prefix}/token`, tokenValue); + setStorage(`${prefix}/expire`, expiry); + setStorage(`${prefix}/scopes`, scopes); +}; + +/** + * Set the active token in the local storage. + */ +export const updateCurrentTokenBasedOnUserType = ({ + userType, +}: { + userType: 'parent' | 'proxy'; +}) => { + const storageKeyPrefix = `authentication/${userType}_token`; + + const userToken = getStorage(`${storageKeyPrefix}/token`); + const userScope = getStorage(`${storageKeyPrefix}/scopes`); + const userExpiry = getStorage(`${storageKeyPrefix}/expire`); + + if (userToken) { + setStorage('authentication/token', userToken); + setStorage('authentication/scopes', userScope); + setStorage('authentication/expire', userExpiry); + } +}; diff --git a/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx b/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx index 047aa312c10..8077365171a 100644 --- a/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx +++ b/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx @@ -1,9 +1,10 @@ +import { GlobalGrantTypes } from '@linode/api-v4/lib/account'; import KeyboardArrowDown from '@mui/icons-material/KeyboardArrowDown'; import KeyboardArrowUp from '@mui/icons-material/KeyboardArrowUp'; import { Theme, styled, useMediaQuery } from '@mui/material'; import Popover from '@mui/material/Popover'; import Grid from '@mui/material/Unstable_Grid2'; -import { AxiosHeaders } from 'axios'; +import { useSnackbar } from 'notistack'; import * as React from 'react'; import { Box } from 'src/components/Box'; @@ -17,13 +18,11 @@ import { Tooltip } from 'src/components/Tooltip'; import { Typography } from 'src/components/Typography'; import { SwitchAccountButton } from 'src/features/Account/SwitchAccountButton'; import { SwitchAccountDrawer } from 'src/features/Account/SwitchAccountDrawer'; -import { useAccountManagement } from 'src/hooks/useAccountManagement'; import { useFlags } from 'src/hooks/useFlags'; +import { useAccount } from 'src/queries/account'; import { useAccountUser } from 'src/queries/accountUsers'; import { useGrants, useProfile } from 'src/queries/profile'; -import { authentication } from 'src/utilities/storage'; - -import type { UserType } from '@linode/api-v4'; +import { getStorage } from 'src/utilities/storage'; interface MenuLink { display: string; @@ -50,35 +49,51 @@ const profileLinks: MenuLink[] = [ ]; export const UserMenu = React.memo(() => { - const { - _hasAccountAccess, - _isRestrictedUser, - account, - profile, - } = useAccountManagement(); - - const flags = useFlags(); + const [anchorEl, setAnchorEl] = React.useState( + null + ); + const [isDrawerOpen, setIsDrawerOpen] = React.useState(false); + const { data: account } = useAccount(); + const { data: profile } = useProfile(); const { data: user } = useAccountUser(profile?.username ?? ''); const { data: grants } = useGrants(); + const { enqueueSnackbar } = useSnackbar(); + const flags = useFlags(); - // For proxy accounts: configure request headers using a parent's token to fetch the parent's username from /profile. - const headers = - flags.parentChildAccountAccess && user?.user_type === 'proxy' - ? new AxiosHeaders({ Authorization: authentication.token.get() }) // TODO: Parent/Child - M3-7430: replace this token with the parent token in local storage. + const hasGrant = (grant: GlobalGrantTypes) => + grants?.global?.[grant] ?? false; + const isRestrictedUser = profile?.restricted ?? false; + const hasAccountAccess = !isRestrictedUser || hasGrant('account_access'); + const hasReadWriteAccountAccess = hasGrant('account_access') === 'read_write'; + const hasParentChildAccountAccess = Boolean(flags.parentChildAccountAccess); + const isParentUser = user?.user_type === 'parent'; + const isProxyUser = user?.user_type === 'proxy'; + const canSwitchBetweenParentOrProxyAccount = + hasParentChildAccountAccess && (isParentUser || isProxyUser); + const open = Boolean(anchorEl); + const id = open ? 'user-menu-popover' : undefined; + const companyName = (user?.user_type && account?.company) ?? ''; + const showCompanyName = hasParentChildAccountAccess && companyName; + + // Used for fetching parent profile and account data by making a request with the parent's token. + const proxyHeaders = + hasParentChildAccountAccess && isProxyUser + ? { + Authorization: getStorage(`authentication/parent_token/token`), + } : undefined; - const { data: parentProfile } = useProfile({ headers }); + const { data: parentProfile } = useProfile({ headers: proxyHeaders }); + + const userName = + (hasParentChildAccountAccess && isProxyUser ? parentProfile : profile) + ?.username ?? ''; const matchesSmDown = useMediaQuery((theme: Theme) => theme.breakpoints.down('sm') ); - const [anchorEl, setAnchorEl] = React.useState( - null - ); - const [isDrawerOpen, setIsDrawerOpen] = React.useState(false); - const handleClick = (event: React.MouseEvent) => { setAnchorEl(event.currentTarget); }; @@ -87,34 +102,14 @@ export const UserMenu = React.memo(() => { setAnchorEl(null); }; - /** - * Use the current profile's username for all accounts but a proxy user account, for which we display the parent's username. - */ - const getUserNameBasedOnUserType = ( - userType: UserType | null | undefined, - isParentChildFeatureEnabled: boolean - ) => { - return isParentChildFeatureEnabled && userType === 'proxy' - ? parentProfile?.username - : profile?.username; - }; - - const open = Boolean(anchorEl); - const id = open ? 'user-menu-popover' : undefined; - const companyName = - user?.user_type && account?.company ? account?.company : ''; - const userName = - getUserNameBasedOnUserType( - user?.user_type, - Boolean(flags.parentChildAccountAccess) - ) ?? ''; - const hasFullAccountAccess = - grants?.global?.account_access === 'read_write' || !_isRestrictedUser; - const showCompanyName = - flags.parentChildAccountAccess && user?.user_type !== null && companyName; - const isAccountSwitchable = - flags.parentChildAccountAccess && - (user?.user_type === 'parent' || user?.user_type === 'proxy'); + React.useEffect(() => { + // Run after we've switched to a proxy user. + if (isProxyUser) { + enqueueSnackbar(`Account switched to ${companyName}.`, { + variant: 'success', + }); + } + }, [isProxyUser, companyName, enqueueSnackbar]); const accountLinks: MenuLink[] = React.useMemo( () => [ @@ -125,13 +120,13 @@ export const UserMenu = React.memo(() => { // Restricted users can't view the Users tab regardless of their grants { display: 'Users & Grants', - hide: _isRestrictedUser, + hide: isRestrictedUser, href: '/account/users', }, // Restricted users can't view the Transfers tab regardless of their grants { display: 'Service Transfers', - hide: _isRestrictedUser, + hide: isRestrictedUser, href: '/account/service-transfers', }, { @@ -141,11 +136,11 @@ export const UserMenu = React.memo(() => { // Restricted users with read_write account access can view Settings. { display: 'Account Settings', - hide: !hasFullAccountAccess, + hide: !hasReadWriteAccountAccess, href: '/account/settings', }, ], - [hasFullAccountAccess, _isRestrictedUser] + [hasReadWriteAccountAccess, isRestrictedUser] ); const renderLink = (link: MenuLink) => { @@ -168,14 +163,15 @@ export const UserMenu = React.memo(() => { }; const getEndIcon = () => { - if (matchesSmDown) { - return undefined; - } - if (open) { - return ; - } - return ( - + const sx = { + height: 26, + width: 26, + }; + + return matchesSmDown ? undefined : open ? ( + + ) : ( + ); }; @@ -189,9 +185,6 @@ export const UserMenu = React.memo(() => { > + + ))} + +); + +const mockHandleSelection = vi.fn(); + +describe('RegionMultiSelect', () => { + it('renders correctly with initial props', () => { + renderWithTheme( + + ); + + screen.getByRole('combobox', { name: 'Regions' }); + }); + + it('should be able to select all the regions correctly', () => { + renderWithTheme( + + ); + + // Open the dropdown + fireEvent.click(screen.getByRole('button', { name: 'Open' })); + + fireEvent.click(screen.getByRole('option', { name: 'Select All' })); + + // Check if all the option is selected + expect( + screen.getByRole('option', { + name: 'Newark, NJ (us-east)', + }) + ).toHaveAttribute('aria-selected', 'true'); + expect( + screen.getByRole('option', { + name: 'Newark, NJ (us-east)', + }) + ).toHaveAttribute('aria-selected', 'true'); + }); + + it('should be able to deselect all the regions', () => { + renderWithTheme( + + ); + + // Open the dropdown + fireEvent.click(screen.getByRole('button', { name: 'Open' })); + + fireEvent.click(screen.getByRole('option', { name: 'Deselect All' })); + + // Check if all the option is deselected selected + expect( + screen.getByRole('option', { + name: 'Newark, NJ (us-east)', + }) + ).toHaveAttribute('aria-selected', 'false'); + expect( + screen.getByRole('option', { + name: 'Newark, NJ (us-east)', + }) + ).toHaveAttribute('aria-selected', 'false'); + }); + + it('should render selected regions correctly', () => { + renderWithTheme( + ( + + )} + currentCapability="Block Storage" + handleSelection={mockHandleSelection} + regions={[...regionsNewark, ...regionsAtlanta]} + selectedIds={[]} + /> + ); + + // Open the dropdown + fireEvent.click(screen.getByRole('button', { name: 'Open' })); + + fireEvent.click(screen.getByRole('option', { name: 'Select All' })); + + // Close the dropdown + fireEvent.click(screen.getByRole('button', { name: 'Close' })); + + // Check if all the options are rendered + expect( + screen.getByRole('listitem', { + name: 'Newark, NJ (us-east)', + }) + ).toBeInTheDocument(); + expect( + screen.getByRole('listitem', { + name: 'Newark, NJ (us-east)', + }) + ).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx b/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx new file mode 100644 index 00000000000..b47e926ac0d --- /dev/null +++ b/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx @@ -0,0 +1,160 @@ +import React, { useEffect, useMemo, useState } from 'react'; + +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; +import { StyledListItem } from 'src/components/Autocomplete/Autocomplete.styles'; +import { useFlags } from 'src/hooks/useFlags'; +import { useAccountAvailabilitiesQueryUnpaginated } from 'src/queries/accountAvailability'; + +import { RegionOption } from './RegionOption'; +import { StyledAutocompleteContainer } from './RegionSelect.styles'; +import { + getRegionOptions, + getSelectedRegionsByIds, +} from './RegionSelect.utils'; + +import type { + RegionMultiSelectProps, + RegionSelectOption, +} from './RegionSelect.types'; + +export const RegionMultiSelect = React.memo((props: RegionMultiSelectProps) => { + const { + SelectedRegionsList, + currentCapability, + disabled, + errorText, + handleSelection, + helperText, + isClearable, + label, + onBlur, + placeholder, + regions, + required, + selectedIds, + sortRegionOptions, + width, + } = props; + + const flags = useFlags(); + const { + data: accountAvailability, + isLoading: accountAvailabilityLoading, + } = useAccountAvailabilitiesQueryUnpaginated(flags.dcGetWell); + + const [selectedRegions, setSelectedRegions] = useState( + getSelectedRegionsByIds({ + accountAvailabilityData: accountAvailability, + currentCapability, + regions, + selectedRegionIds: selectedIds ?? [], + }) + ); + + const handleRegionChange = (selection: RegionSelectOption[]) => { + setSelectedRegions(selection); + const selectedIds = selection.map((region) => region.value); + handleSelection(selectedIds); + }; + + useEffect(() => { + setSelectedRegions( + getSelectedRegionsByIds({ + accountAvailabilityData: accountAvailability, + currentCapability, + regions, + selectedRegionIds: selectedIds ?? [], + }) + ); + }, [selectedIds, accountAvailability, currentCapability, regions]); + + const options = useMemo( + () => + getRegionOptions({ + accountAvailabilityData: accountAvailability, + currentCapability, + regions, + }), + [accountAvailability, currentCapability, regions] + ); + + const handleRemoveOption = (optionToRemove: RegionSelectOption) => { + const updatedSelectedOptions = selectedRegions.filter( + (option) => option.value !== optionToRemove.value + ); + const updatedSelectedIds = updatedSelectedOptions.map( + (region) => region.value + ); + setSelectedRegions(updatedSelectedOptions); + handleSelection(updatedSelectedIds); + }; + + return ( + <> + + + Boolean(flags.dcGetWell) && Boolean(option.unavailable) + } + groupBy={(option: RegionSelectOption) => { + return option?.data?.region; + }} + isOptionEqualToValue={( + option: RegionSelectOption, + value: RegionSelectOption + ) => option.value === value.value} + onChange={(_, selectedOption) => + handleRegionChange(selectedOption as RegionSelectOption[]) + } + renderOption={(props, option, { selected }) => { + if (!option.data) { + // Render options like "Select All / Deselect All " + return {option.label}; + } + + // Render regular options + return ( + + ); + }} + textFieldProps={{ + InputProps: { + required, + }, + tooltipText: helperText, + }} + autoHighlight + clearOnBlur + data-testid="region-select" + disableClearable={!isClearable} + disabled={disabled} + errorText={errorText} + label={label ?? 'Regions'} + loading={accountAvailabilityLoading} + multiple + noOptionsText="No results" + onBlur={onBlur} + options={options} + placeholder={placeholder ?? 'Select Regions'} + renderTags={() => null} + value={selectedRegions} + /> + + {selectedRegions.length > 0 && SelectedRegionsList && ( + + )} + + ); +}); diff --git a/packages/manager/src/components/RegionSelect/RegionOption.tsx b/packages/manager/src/components/RegionSelect/RegionOption.tsx new file mode 100644 index 00000000000..5b4279666b5 --- /dev/null +++ b/packages/manager/src/components/RegionSelect/RegionOption.tsx @@ -0,0 +1,97 @@ +import { visuallyHidden } from '@mui/utils'; +import React from 'react'; + +import { Box } from 'src/components/Box'; +import { Flag } from 'src/components/Flag'; +import { Link } from 'src/components/Link'; +import { Tooltip } from 'src/components/Tooltip'; +import { useFlags } from 'src/hooks/useFlags'; + +import { + SelectedIcon, + StyledFlagContainer, + StyledListItem, +} from './RegionSelect.styles'; +import { RegionSelectOption } from './RegionSelect.types'; + +import type { ListItemComponentsPropsOverrides } from '@mui/material/ListItem'; + +type Props = { + option: RegionSelectOption; + props: React.HTMLAttributes; + selected: boolean; +}; + +export const RegionOption = ({ option, props, selected }: Props) => { + const flags = useFlags(); + const isDisabledMenuItem = + Boolean(flags.dcGetWell) && Boolean(option.unavailable); + + return ( + + There may be limited capacity in this region.{' '} + + Learn more + + . + + ) : ( + '' + ) + } + disableFocusListener={!isDisabledMenuItem} + disableHoverListener={!isDisabledMenuItem} + disableTouchListener={!isDisabledMenuItem} + enterDelay={200} + enterNextDelay={200} + enterTouchDelay={200} + key={option.value} + > + + isDisabledMenuItem + ? e.preventDefault() + : props.onClick + ? props.onClick(e) + : null + } + aria-disabled={undefined} + > + <> + + + + + {option.label} + {isDisabledMenuItem && ( + + Disabled option - There may be limited capacity in this region. + Learn more at + https://www.linode.com/global-infrastructure/availability. + + )} + + {selected && } + + + + ); +}; diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.tsx b/packages/manager/src/components/RegionSelect/RegionSelect.tsx index 541ccdf6bbe..de565a9086e 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.tsx +++ b/packages/manager/src/components/RegionSelect/RegionSelect.tsx @@ -1,19 +1,14 @@ -import { visuallyHidden } from '@mui/utils'; import * as React from 'react'; import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; -import { Box } from 'src/components/Box'; import { Flag } from 'src/components/Flag'; -import { Link } from 'src/components/Link'; -import { Tooltip } from 'src/components/Tooltip'; import { useFlags } from 'src/hooks/useFlags'; import { useAccountAvailabilitiesQueryUnpaginated } from 'src/queries/accountAvailability'; +import { RegionOption } from './RegionOption'; import { - SelectedIcon, StyledAutocompleteContainer, StyledFlagContainer, - StyledListItem, } from './RegionSelect.styles'; import { getRegionOptions, getSelectedRegionById } from './RegionSelect.utils'; @@ -21,7 +16,6 @@ import type { RegionSelectOption, RegionSelectProps, } from './RegionSelect.types'; -import type { ListItemComponentsPropsOverrides } from '@mui/material/ListItem'; /** * A specific select for regions. @@ -104,74 +98,13 @@ export const RegionSelect = React.memo((props: RegionSelectProps) => { setSelectedRegion(null); }} renderOption={(props, option, { selected }) => { - const isDisabledMenuItem = - Boolean(flags.dcGetWell) && Boolean(option.unavailable); return ( - - There may be limited capacity in this region.{' '} - - Learn more - - . - - ) : ( - '' - ) - } - disableFocusListener={!isDisabledMenuItem} - disableHoverListener={!isDisabledMenuItem} - disableTouchListener={!isDisabledMenuItem} - enterDelay={200} - enterNextDelay={200} - enterTouchDelay={200} + - - isDisabledMenuItem - ? e.preventDefault() - : props.onClick - ? props.onClick(e) - : null - } - aria-disabled={undefined} - > - <> - - - - - {option.label} - {isDisabledMenuItem && ( - - Disabled option - There may be limited capacity in this - region. Learn more at - https://www.linode.com/global-infrastructure/availability. - - )} - - {selected && } - - - + option={option} + props={props} + selected={selected} + /> ); }} textFieldProps={{ diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.types.ts b/packages/manager/src/components/RegionSelect/RegionSelect.types.ts index 1edd925a586..708957002a8 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.types.ts +++ b/packages/manager/src/components/RegionSelect/RegionSelect.types.ts @@ -39,6 +39,27 @@ export interface RegionSelectProps width?: number; } +export interface RegionMultiSelectProps + extends Omit< + EnhancedAutocompleteProps, + 'label' | 'onChange' | 'options' + > { + SelectedRegionsList?: React.ComponentType<{ + onRemove: (option: RegionSelectOption) => void; + selectedRegions: RegionSelectOption[]; + }>; + currentCapability: Capabilities | undefined; + handleSelection: (ids: string[]) => void; + helperText?: string; + isClearable?: boolean; + label?: string; + regions: Region[]; + required?: boolean; + selectedIds: string[]; + sortRegionOptions?: (a: RegionSelectOption, b: RegionSelectOption) => number; + width?: number; +} + export interface RegionOptionAvailability { accountAvailabilityData: AccountAvailability[] | undefined; currentCapability: Capabilities | undefined; @@ -56,3 +77,10 @@ export interface GetSelectedRegionById extends RegionOptionAvailability { export interface GetRegionOptionAvailability extends RegionOptionAvailability { region: Region; } + +export interface GetSelectedRegionsByIdsArgs { + accountAvailabilityData: AccountAvailability[] | undefined; + currentCapability: Capabilities | undefined; + regions: Region[]; + selectedRegionIds: string[]; +} diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.utils.test.tsx b/packages/manager/src/components/RegionSelect/RegionSelect.utils.test.tsx index 57359016ddd..f7a4b1c2acc 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.utils.test.tsx +++ b/packages/manager/src/components/RegionSelect/RegionSelect.utils.test.tsx @@ -4,6 +4,7 @@ import { getRegionOptionAvailability, getRegionOptions, getSelectedRegionById, + getSelectedRegionsByIds, } from './RegionSelect.utils'; import type { RegionSelectOption } from './RegionSelect.types'; @@ -168,3 +169,64 @@ describe('getRegionOptionAvailability', () => { expect(result).toBe(false); }); }); + +describe('getSelectedRegionsByIds', () => { + it('should return an array of RegionSelectOptions for the given selectedRegionIds', () => { + const selectedRegionIds = ['us-1', 'ca-1']; + + const result = getSelectedRegionsByIds({ + accountAvailabilityData, + currentCapability: 'Linodes', + regions, + selectedRegionIds, + }); + + const expected = [ + { + data: { + country: 'us', + region: 'North America', + }, + label: 'US Location (us-1)', + unavailable: false, + value: 'us-1', + }, + { + data: { + country: 'ca', + region: 'North America', + }, + label: 'CA Location (ca-1)', + unavailable: false, + value: 'ca-1', + }, + ]; + + expect(result).toEqual(expected); + }); + + it('should exclude regions that are not found in the regions array', () => { + const selectedRegionIds = ['us-1', 'non-existent-region']; + + const result = getSelectedRegionsByIds({ + accountAvailabilityData, + currentCapability: 'Linodes', + regions, + selectedRegionIds, + }); + + const expected = [ + { + data: { + country: 'us', + region: 'North America', + }, + label: 'US Location (us-1)', + unavailable: false, + value: 'us-1', + }, + ]; + + expect(result).toEqual(expected); + }); +}); diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.utils.ts b/packages/manager/src/components/RegionSelect/RegionSelect.utils.ts index ad6ff2d6f62..d5f54ca0304 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.utils.ts +++ b/packages/manager/src/components/RegionSelect/RegionSelect.utils.ts @@ -9,6 +9,7 @@ import type { GetRegionOptionAvailability, GetRegionOptions, GetSelectedRegionById, + GetSelectedRegionsByIdsArgs, RegionSelectOption, } from './RegionSelect.types'; import type { AccountAvailability, Region } from '@linode/api-v4'; @@ -151,3 +152,26 @@ export const getRegionOptionAvailability = ({ return regionWithUnavailability.unavailable.includes(currentCapability); }; + +/** + * This utility function takes an array of region IDs and returns an array of corresponding RegionSelectOption objects. + * + * @returns An array of RegionSelectOption objects corresponding to the selected region IDs. + */ +export const getSelectedRegionsByIds = ({ + accountAvailabilityData, + currentCapability, + regions, + selectedRegionIds, +}: GetSelectedRegionsByIdsArgs): RegionSelectOption[] => { + return selectedRegionIds + .map((selectedRegionId) => + getSelectedRegionById({ + accountAvailabilityData, + currentCapability, + regions, + selectedRegionId, + }) + ) + .filter((region): region is RegionSelectOption => !!region); +}; diff --git a/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.tsx b/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.tsx index 706d4ada54a..3b16017e78f 100644 --- a/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.tsx +++ b/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.tsx @@ -22,6 +22,10 @@ export type RemovableItem = { } & { [key: string]: any }; export interface RemovableSelectionsListProps { + /** + * The custom label component + */ + LabelComponent?: React.ComponentType<{ selection: RemovableItem }>; /** * The descriptive text to display above the list */ @@ -62,6 +66,7 @@ export const RemovableSelectionsList = ( props: RemovableSelectionsListProps ) => { const { + LabelComponent, headerText, isRemovable = true, maxHeight = 427, @@ -99,9 +104,13 @@ export const RemovableSelectionsList = ( {selectionData.map((selection) => ( - {preferredDataLabel - ? selection[preferredDataLabel] - : selection.label} + {LabelComponent ? ( + + ) : preferredDataLabel ? ( + selection[preferredDataLabel] + ) : ( + selection.label + )} {isRemovable && ( Date: Tue, 23 Jan 2024 13:21:29 -0500 Subject: [PATCH 12/44] chore(deps-dev): Bump vite from 5.0.7 to 5.0.12 (#10087) Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.0.7 to 5.0.12. - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/v5.0.12/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v5.0.12/packages/vite) --- updated-dependencies: - dependency-name: vite dependency-type: direct:development ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- packages/manager/package.json | 2 +- yarn.lock | 35 +++++------------------------------ 2 files changed, 6 insertions(+), 31 deletions(-) diff --git a/packages/manager/package.json b/packages/manager/package.json index 933604a5c8c..f77b74383d8 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -207,7 +207,7 @@ "storybook": "~7.6.4", "storybook-dark-mode": "^3.0.3", "ts-node": "^10.9.2", - "vite": "^5.0.7", + "vite": "^5.0.12", "vite-plugin-svgr": "^3.2.0", "vitest": "^1.2.0" }, diff --git a/yarn.lock b/yarn.lock index 6035d09a326..3797e0ddeb7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11082,11 +11082,6 @@ nanoclone@^0.2.1: resolved "https://registry.yarnpkg.com/nanoclone/-/nanoclone-0.2.1.tgz#dd4090f8f1a110d26bb32c49ed2f5b9235209ed4" integrity sha512-wynEP02LmIbLpcYw8uBKpcfF6dmg2vcpKqxeH5UcoKEYdExslsdUA4ugFauuaeYdTB76ez6gJW8XAZ6CgkXYxA== -nanoid@^3.3.6: - version "3.3.6" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.6.tgz#443380c856d6e9f9824267d960b4236ad583ea4c" - integrity sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA== - nanoid@^3.3.7: version "3.3.7" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8" @@ -11793,16 +11788,7 @@ postcss-load-config@^4.0.1: lilconfig "^2.0.5" yaml "^2.1.1" -postcss@^8.3.11: - version "8.4.31" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.31.tgz#92b451050a9f914da6755af352bdc0192508656d" - integrity sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ== - dependencies: - nanoid "^3.3.6" - picocolors "^1.0.0" - source-map-js "^1.0.2" - -postcss@^8.4.32: +postcss@^8.3.11, postcss@^8.4.32: version "8.4.32" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.32.tgz#1dac6ac51ab19adb21b8b34fd2d93a86440ef6c9" integrity sha512-D/kj5JNu6oo2EIy+XL/26JEDTlIbB8hw85G8StOE6L74RQAVVP5rej6wxCNqyMbR4RkPfqvezVbPw81Ngd6Kcw== @@ -14473,21 +14459,10 @@ vite-plugin-svgr@^3.2.0: "@svgr/core" "^7.0.0" "@svgr/plugin-jsx" "^7.0.0" -vite@^5.0.0, vite@^5.0.7: - version "5.0.7" - resolved "https://registry.yarnpkg.com/vite/-/vite-5.0.7.tgz#ad081d735f6769f76b556818500bdafb72c3fe93" - integrity sha512-B4T4rJCDPihrQo2B+h1MbeGL/k/GMAHzhQ8S0LjQ142s6/+l3hHTT095ORvsshj4QCkoWu3Xtmob5mazvakaOw== - dependencies: - esbuild "^0.19.3" - postcss "^8.4.32" - rollup "^4.2.0" - optionalDependencies: - fsevents "~2.3.3" - -"vite@^5.0.0-beta.15 || ^5.0.0", "vite@^5.0.0-beta.19 || ^5.0.0": - version "5.0.5" - resolved "https://registry.yarnpkg.com/vite/-/vite-5.0.5.tgz#3eebe3698e3b32cea36350f58879258fec858a3c" - integrity sha512-OekeWqR9Ls56f3zd4CaxzbbS11gqYkEiBtnWFFgYR2WV8oPJRRKq0mpskYy/XaoCL3L7VINDhqqOMNDiYdGvGg== +vite@^5.0.0, "vite@^5.0.0-beta.15 || ^5.0.0", "vite@^5.0.0-beta.19 || ^5.0.0", vite@^5.0.12: + version "5.0.12" + resolved "https://registry.yarnpkg.com/vite/-/vite-5.0.12.tgz#8a2ffd4da36c132aec4adafe05d7adde38333c47" + integrity sha512-4hsnEkG3q0N4Tzf1+t6NdN9dg/L3BM+q8SWgbSPnJvrgH2kgdyzfVJwbR1ic69/4uMJJ/3dqDZZE5/WwqW8U1w== dependencies: esbuild "^0.19.3" postcss "^8.4.32" From 30d7ccfdfa94fcadc2bc9d0e620f8c3435b51499 Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Wed, 24 Jan 2024 11:29:15 -0500 Subject: [PATCH 13/44] chore: [M3-7474] Update storybook & add @babel/traverse resolution (#10097) * Update storybook & add @babel/traverse resolution * Added changeset: Update storybook & add @babel/traverse resolution --- package.json | 1 + .../pr-10097-tech-stories-1706039289349.md | 5 + packages/manager/package.json | 25 +- yarn.lock | 1258 ++++++++++++----- 4 files changed, 951 insertions(+), 338 deletions(-) create mode 100644 packages/manager/.changeset/pr-10097-tech-stories-1706039289349.md diff --git a/package.json b/package.json index c1d55d45cee..67848f39cb2 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "docs": "bunx vitepress@1.0.0-rc.35 dev docs" }, "resolutions": { + "@babel/traverse": "^7.23.3", "minimist": "^1.2.3", "yargs-parser": "^18.1.3", "kind-of": "^6.0.3", diff --git a/packages/manager/.changeset/pr-10097-tech-stories-1706039289349.md b/packages/manager/.changeset/pr-10097-tech-stories-1706039289349.md new file mode 100644 index 00000000000..c41267d652b --- /dev/null +++ b/packages/manager/.changeset/pr-10097-tech-stories-1706039289349.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Update storybook & add @babel/traverse resolution ([#10097](https://github.com/linode/manager/pull/10097)) diff --git a/packages/manager/package.json b/packages/manager/package.json index f77b74383d8..6410b928ab1 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -110,17 +110,18 @@ }, "devDependencies": { "@linode/eslint-plugin-cloud-manager": "^0.0.3", - "@storybook/addon-actions": "~7.6.4", - "@storybook/addon-controls": "~7.6.4", - "@storybook/addon-docs": "~7.6.4", - "@storybook/addon-measure": "~7.6.4", - "@storybook/addon-storysource": "^7.6.4", - "@storybook/addon-viewport": "~7.6.4", - "@storybook/addons": "~7.6.4", - "@storybook/client-api": "~7.6.4", - "@storybook/react": "~7.6.4", - "@storybook/react-vite": "^7.6.4", - "@storybook/theming": "~7.6.4", + "@storybook/addon-actions": "^7.6.10", + "@storybook/addon-controls": "^7.6.10", + "@storybook/addon-docs": "^7.6.10", + "@storybook/addon-mdx-gfm": "^7.6.10", + "@storybook/addon-measure": "^7.6.10", + "@storybook/addon-storysource": "^7.6.10", + "@storybook/addon-viewport": "^7.6.10", + "@storybook/addons": "^7.6.10", + "@storybook/client-api": "^7.6.10", + "@storybook/react": "^7.6.10", + "@storybook/react-vite": "^7.6.10", + "@storybook/theming": "^7.6.10", "@swc/core": "^1.3.1", "@testing-library/cypress": "^10.0.0", "@testing-library/jest-dom": "~5.11.3", @@ -204,7 +205,7 @@ "redux-mock-store": "^1.5.3", "reselect-tools": "^0.0.7", "serve": "^14.0.1", - "storybook": "~7.6.4", + "storybook": "^7.6.10", "storybook-dark-mode": "^3.0.3", "ts-node": "^10.9.2", "vite": "^5.0.12", diff --git a/yarn.lock b/yarn.lock index 3797e0ddeb7..bf3d60be1bb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -263,7 +263,7 @@ json5 "^2.2.3" semver "^6.3.1" -"@babel/generator@^7.21.0", "@babel/generator@^7.21.1": +"@babel/generator@^7.21.0": version "7.21.1" resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.21.1.tgz#951cc626057bc0af2c35cd23e9c64d384dea83dd" integrity sha512-1lT45bAYlQhFn/BHivJs43AiW2rg3/UbLyShGfF3C0KmHvO5fSghWd5kBJy30kpRRucGzXStvnnCFniCR2kXAA== @@ -273,7 +273,7 @@ "@jridgewell/trace-mapping" "^0.3.17" jsesc "^2.5.1" -"@babel/generator@^7.22.0", "@babel/generator@^7.22.3": +"@babel/generator@^7.22.0": version "7.22.3" resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.22.3.tgz#0ff675d2edb93d7596c5f6728b52615cfc0df01e" integrity sha512-C17MW4wlk//ES/CJDL51kPNwl+qiBQyN7b9SKyVp11BLGFeSPoVaHrv+MNt8jwQFhQWowW88z1eeBx3pFz9v8A== @@ -441,14 +441,6 @@ resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz#96159db61d34a29dba454c959f5ae4a649ba9167" integrity sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA== -"@babel/helper-function-name@^7.21.0": - version "7.21.0" - resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.21.0.tgz#d552829b10ea9f120969304023cd0645fa00b1b4" - integrity sha512-HfK1aMRanKHpxemaY2gqBmL04iAPOPRj7DxtNbiDOrJK+gdwkiNRVpCpUJYbUT+aZyemKN8brqTOxzCaG6ExRg== - dependencies: - "@babel/template" "^7.20.7" - "@babel/types" "^7.21.0" - "@babel/helper-function-name@^7.22.5", "@babel/helper-function-name@^7.23.0": version "7.23.0" resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz#1f9a3cdbd5b2698a670c30d2735f9af95ed52759" @@ -457,13 +449,6 @@ "@babel/template" "^7.22.15" "@babel/types" "^7.23.0" -"@babel/helper-hoist-variables@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.18.6.tgz#d4d2c8fb4baeaa5c68b99cc8245c56554f926678" - integrity sha512-UlJQPkFqFULIcyW5sbzgbkxn2FKRgwWiRexcuaR8RNJRy8+LLveqPjwZV/bwrLZCN0eUHD/x8D0heK1ozuoo6Q== - dependencies: - "@babel/types" "^7.18.6" - "@babel/helper-hoist-variables@^7.22.5": version "7.22.5" resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz#c01a007dac05c085914e8fb652b339db50d823bb" @@ -743,12 +728,12 @@ chalk "^2.4.2" js-tokens "^4.0.0" -"@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.20.7", "@babel/parser@^7.21.0", "@babel/parser@^7.21.2", "@babel/parser@^7.7.0": +"@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.20.7", "@babel/parser@^7.21.0", "@babel/parser@^7.7.0": version "7.21.2" resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.21.2.tgz#dacafadfc6d7654c3051a66d6fe55b6cb2f2a0b3" integrity sha512-URpaIJQwEkEC2T9Kn+Ai6Xe/02iNaVCuT/PtoRz3GPVJVDpPd7mLo+VddTbhCRU9TXqW5mSrQfXZyi8kDKOVpQ== -"@babel/parser@^7.21.9", "@babel/parser@^7.22.0", "@babel/parser@^7.22.4": +"@babel/parser@^7.21.9", "@babel/parser@^7.22.0": version "7.22.4" resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.22.4.tgz#a770e98fd785c231af9d93f6459d36770993fb32" integrity sha512-VLLsx06XkEYqBtE5YGPwfSGwfrjnyPP5oiGty3S8pQLFDFLaS8VwWSIxkTXpcvr5zeYLE6+MBNl2npl/YnfofA== @@ -1595,58 +1580,10 @@ "@babel/parser" "^7.22.15" "@babel/types" "^7.22.15" -"@babel/traverse@^7.18.9", "@babel/traverse@^7.23.2": - version "7.23.2" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.23.2.tgz#329c7a06735e144a506bdb2cad0268b7f46f4ad8" - integrity sha512-azpe59SQ48qG6nu2CzcMLbxUudtN+dOM9kDbUqGq3HXUJRlo7i8fvPoxQUzYgLZ4cMVmuZgm8vvBpNeRhd6XSw== - dependencies: - "@babel/code-frame" "^7.22.13" - "@babel/generator" "^7.23.0" - "@babel/helper-environment-visitor" "^7.22.20" - "@babel/helper-function-name" "^7.23.0" - "@babel/helper-hoist-variables" "^7.22.5" - "@babel/helper-split-export-declaration" "^7.22.6" - "@babel/parser" "^7.23.0" - "@babel/types" "^7.23.0" - debug "^4.1.0" - globals "^11.1.0" - -"@babel/traverse@^7.21.0", "@babel/traverse@^7.21.2", "@babel/traverse@^7.7.0": - version "7.21.2" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.21.2.tgz#ac7e1f27658750892e815e60ae90f382a46d8e75" - integrity sha512-ts5FFU/dSUPS13tv8XiEObDu9K+iagEKME9kAbaP7r0Y9KtZJZ+NGndDvWoRAYNpeWafbpFeki3q9QoMD6gxyw== - dependencies: - "@babel/code-frame" "^7.18.6" - "@babel/generator" "^7.21.1" - "@babel/helper-environment-visitor" "^7.18.9" - "@babel/helper-function-name" "^7.21.0" - "@babel/helper-hoist-variables" "^7.18.6" - "@babel/helper-split-export-declaration" "^7.18.6" - "@babel/parser" "^7.21.2" - "@babel/types" "^7.21.2" - debug "^4.1.0" - globals "^11.1.0" - -"@babel/traverse@^7.22.1": - version "7.22.4" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.22.4.tgz#c3cf96c5c290bd13b55e29d025274057727664c0" - integrity sha512-Tn1pDsjIcI+JcLKq1AVlZEr4226gpuAQTsLMorsYg9tuS/kG7nuwwJ4AB8jfQuEgb/COBwR/DqJxmoiYFu5/rQ== - dependencies: - "@babel/code-frame" "^7.21.4" - "@babel/generator" "^7.22.3" - "@babel/helper-environment-visitor" "^7.22.1" - "@babel/helper-function-name" "^7.21.0" - "@babel/helper-hoist-variables" "^7.18.6" - "@babel/helper-split-export-declaration" "^7.18.6" - "@babel/parser" "^7.22.4" - "@babel/types" "^7.22.4" - debug "^4.1.0" - globals "^11.1.0" - -"@babel/traverse@^7.23.6": - version "7.23.6" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.23.6.tgz#b53526a2367a0dd6edc423637f3d2d0f2521abc5" - integrity sha512-czastdK1e8YByZqezMPFiZ8ahwVMh/ESl9vPgvgdB9AmFMGP5jfpFax74AQgl5zj4XHzqeYAg2l8PuUeRS1MgQ== +"@babel/traverse@^7.18.9", "@babel/traverse@^7.21.0", "@babel/traverse@^7.21.2", "@babel/traverse@^7.22.1", "@babel/traverse@^7.23.2", "@babel/traverse@^7.23.6", "@babel/traverse@^7.7.0": + version "7.23.7" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.23.7.tgz#9a7bf285c928cb99b5ead19c3b1ce5b310c9c305" + integrity sha512-tY3mM8rH9jM0YHFGyfC0/xf+SB5eKUu7HPj7/k3fpi9dAlsMc5YbQvDi0Sh2QTPXqMhyaAtzAr807TIyfQrmyg== dependencies: "@babel/code-frame" "^7.23.5" "@babel/generator" "^7.23.6" @@ -1668,7 +1605,7 @@ "@babel/helper-validator-identifier" "^7.19.1" to-fast-properties "^2.0.0" -"@babel/types@^7.21.3", "@babel/types@^7.21.5", "@babel/types@^7.22.0", "@babel/types@^7.22.3", "@babel/types@^7.22.4": +"@babel/types@^7.21.3", "@babel/types@^7.21.5", "@babel/types@^7.22.0", "@babel/types@^7.22.3": version "7.22.4" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.22.4.tgz#56a2653ae7e7591365dabf20b76295410684c071" integrity sha512-Tx9x3UBHTTsMSW85WB2kphxYQVvrZ/t1FxD88IpSgIjiUJlCm9z+xWIDwyo1vffTwSqteqyznB8ZE9vYYk16zA== @@ -3213,73 +3150,82 @@ resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.8.tgz#6667fac16c436b5434a387a34dedb013198f6e6e" integrity sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA== -"@storybook/addon-actions@~7.6.4": - version "7.6.4" - resolved "https://registry.yarnpkg.com/@storybook/addon-actions/-/addon-actions-7.6.4.tgz#b2d2941baa926718f0c53581c8cc8b19c2e0393b" - integrity sha512-91UD5KPDik74VKVioPMcbwwvDXN/non8p1wArYAHCHCmd/Pts5MJRiFueSdfomSpNjUtjtn6eSXtwpIL3XVOfQ== +"@storybook/addon-actions@^7.6.10": + version "7.6.10" + resolved "https://registry.yarnpkg.com/@storybook/addon-actions/-/addon-actions-7.6.10.tgz#5b43534e158797114db032f4ad8505a81809ed00" + integrity sha512-pcKmf0H/caGzKDy8cz1adNSjv+KOBWLJ11RzGExrWm+Ad5ACifwlsQPykJ3TQ/21sTd9IXVrE9uuq4LldEnPbg== dependencies: - "@storybook/core-events" "7.6.4" + "@storybook/core-events" "7.6.10" "@storybook/global" "^5.0.0" "@types/uuid" "^9.0.1" dequal "^2.0.2" polished "^4.2.2" uuid "^9.0.0" -"@storybook/addon-controls@~7.6.4": - version "7.6.4" - resolved "https://registry.yarnpkg.com/@storybook/addon-controls/-/addon-controls-7.6.4.tgz#8f8651a1e929f5f8506d025bcc5c3e454444b1c3" - integrity sha512-k4AtZfazmD/nL3JAtLGAB7raPhkhUo0jWnaZWrahd9h1Fm13mBU/RW+JzTRhCw3Mp2HPERD7NI5Qcd2fUP6WDA== +"@storybook/addon-controls@^7.6.10": + version "7.6.10" + resolved "https://registry.yarnpkg.com/@storybook/addon-controls/-/addon-controls-7.6.10.tgz#6cd309440bf2b86c21f11a8b5f20bc1340d6c045" + integrity sha512-LjwCQRMWq1apLtFwDi6U8MI6ITUr+KhxJucZ60tfc58RgB2v8ayozyDAonFEONsx9YSR1dNIJ2Z/e2rWTBJeYA== dependencies: - "@storybook/blocks" "7.6.4" + "@storybook/blocks" "7.6.10" lodash "^4.17.21" ts-dedent "^2.0.0" -"@storybook/addon-docs@~7.6.4": - version "7.6.4" - resolved "https://registry.yarnpkg.com/@storybook/addon-docs/-/addon-docs-7.6.4.tgz#e15ab003482d5c43da43edae8557ac6e0988017e" - integrity sha512-PbFMbvC9sK3sGdMhwmagXs9TqopTp9FySji+L8O7W9SHRC6wSmdwoWWPWybkOYxr/z/wXi7EM0azSAX7yQxLbw== +"@storybook/addon-docs@^7.6.10": + version "7.6.10" + resolved "https://registry.yarnpkg.com/@storybook/addon-docs/-/addon-docs-7.6.10.tgz#aab69f253a9cfbb57fd84062f00fac08f9c796cd" + integrity sha512-GtyQ9bMx1AOOtl6ZS9vwK104HFRK+tqzxddRRxhXkpyeKu3olm9aMgXp35atE/3fJSqyyDm2vFtxxH8mzBA20A== dependencies: "@jest/transform" "^29.3.1" "@mdx-js/react" "^2.1.5" - "@storybook/blocks" "7.6.4" - "@storybook/client-logger" "7.6.4" - "@storybook/components" "7.6.4" - "@storybook/csf-plugin" "7.6.4" - "@storybook/csf-tools" "7.6.4" + "@storybook/blocks" "7.6.10" + "@storybook/client-logger" "7.6.10" + "@storybook/components" "7.6.10" + "@storybook/csf-plugin" "7.6.10" + "@storybook/csf-tools" "7.6.10" "@storybook/global" "^5.0.0" "@storybook/mdx2-csf" "^1.0.0" - "@storybook/node-logger" "7.6.4" - "@storybook/postinstall" "7.6.4" - "@storybook/preview-api" "7.6.4" - "@storybook/react-dom-shim" "7.6.4" - "@storybook/theming" "7.6.4" - "@storybook/types" "7.6.4" + "@storybook/node-logger" "7.6.10" + "@storybook/postinstall" "7.6.10" + "@storybook/preview-api" "7.6.10" + "@storybook/react-dom-shim" "7.6.10" + "@storybook/theming" "7.6.10" + "@storybook/types" "7.6.10" fs-extra "^11.1.0" remark-external-links "^8.0.0" remark-slug "^6.0.0" ts-dedent "^2.0.0" -"@storybook/addon-measure@~7.6.4": - version "7.6.4" - resolved "https://registry.yarnpkg.com/@storybook/addon-measure/-/addon-measure-7.6.4.tgz#0597ae04a27ff596bc8f2015f7c746b33eef78a3" - integrity sha512-73wsJ8PALsgWniR3MA/cmxcFuU6cRruWdIyYzOMgM8ife2Jm3xSkV7cTTXAqXt2H9Uuki4PGnuMHWWFLpPeyVA== +"@storybook/addon-mdx-gfm@^7.6.10": + version "7.6.10" + resolved "https://registry.yarnpkg.com/@storybook/addon-mdx-gfm/-/addon-mdx-gfm-7.6.10.tgz#6b71b6d3f8739315b3294564e7e308b7dd3e465f" + integrity sha512-gA1kQZJ4ZKOpi9afu7WRC1twCwZR0J1Nd7u47kNq+5coW1GH9uqGDFYHzr4mfKdD1J09/OrmfMnVjCPx9MYDtQ== + dependencies: + "@storybook/node-logger" "7.6.10" + remark-gfm "^3.0.1" + ts-dedent "^2.0.0" + +"@storybook/addon-measure@^7.6.10": + version "7.6.10" + resolved "https://registry.yarnpkg.com/@storybook/addon-measure/-/addon-measure-7.6.10.tgz#5e41d64aa6e02b9c6df1696d918058979598250e" + integrity sha512-OVfTI56+kc4hLWfZ/YPV3WKj/aA9e4iKXYxZyPdhfX4Z8TgZdD1wv9Z6e8DKS0H5kuybYrHKHaID5ki6t7qz3w== dependencies: "@storybook/global" "^5.0.0" tiny-invariant "^1.3.1" -"@storybook/addon-storysource@^7.6.4": - version "7.6.4" - resolved "https://registry.yarnpkg.com/@storybook/addon-storysource/-/addon-storysource-7.6.4.tgz#a94c1127af66ed90c149b89a2648e1fdc8739326" - integrity sha512-D63IB8bkqn5ZDq4yjvkcLVfGz3OcAQUohlxSFR1e7COo8jMSTiQWjN7xaVPNOnVJRCj6GrlRlto/hqGl+F+WiQ== +"@storybook/addon-storysource@^7.6.10": + version "7.6.10" + resolved "https://registry.yarnpkg.com/@storybook/addon-storysource/-/addon-storysource-7.6.10.tgz#dd277b88069b00d9a325076c2c476a9f8e3f30e2" + integrity sha512-ZtMiO26Bqd2oEovEeJ5ulvIL/rsAuHHpjAgBRZd/Byw25DQKY3GTqGtV474Wjm5tzj7HWhfk69fqAv87HnveCw== dependencies: - "@storybook/source-loader" "7.6.4" + "@storybook/source-loader" "7.6.10" estraverse "^5.2.0" tiny-invariant "^1.3.1" -"@storybook/addon-viewport@~7.6.4": - version "7.6.4" - resolved "https://registry.yarnpkg.com/@storybook/addon-viewport/-/addon-viewport-7.6.4.tgz#f39601082f48a903c47cae71a84b05428ea20331" - integrity sha512-SoTcHIoqybhYD28v7QExF1EZnl7FfxuP74VDhtze5LyMd2CbqmVnUfwewLCz/3IvCNce0GqdNyg1m6QJ7Eq1uw== +"@storybook/addon-viewport@^7.6.10": + version "7.6.10" + resolved "https://registry.yarnpkg.com/@storybook/addon-viewport/-/addon-viewport-7.6.10.tgz#834bad76a56e4117ffb2dc935d349dca3b49bcc3" + integrity sha512-+bA6juC/lH4vEhk+w0rXakaG8JgLG4MOYrIudk5vJKQaC6X58LIM9N4kzIS2KSExRhkExXBPrWsnMfCo7uxmKg== dependencies: memoizerific "^1.11.3" @@ -3292,31 +3238,31 @@ "@storybook/preview-api" "7.0.7" "@storybook/types" "7.0.7" -"@storybook/addons@~7.6.4": - version "7.6.4" - resolved "https://registry.yarnpkg.com/@storybook/addons/-/addons-7.6.4.tgz#35d1cf97e8dc1477fc154bfec798d4b829889519" - integrity sha512-YnmLyR/ciALtzoi9HEu+Y+NJWeOVEBo9PRgQaG7zGiNDvOrLY69uU3Ej0+TZlrTqBqce42bRCrDINJfnk0Mfsg== +"@storybook/addons@^7.6.10": + version "7.6.10" + resolved "https://registry.yarnpkg.com/@storybook/addons/-/addons-7.6.10.tgz#dfc8b71581dd38acd6813f2339ade2f299c28c63" + integrity sha512-lv/oT4ZGMKfXh6bB7LbuRP85bwRprBPYuMMl+e1Ikvu5WTfqVoJRYjc7mvXaIHGCI6DZ/nFcbRjra6q8ZhoDgw== dependencies: - "@storybook/manager-api" "7.6.4" - "@storybook/preview-api" "7.6.4" - "@storybook/types" "7.6.4" + "@storybook/manager-api" "7.6.10" + "@storybook/preview-api" "7.6.10" + "@storybook/types" "7.6.10" -"@storybook/blocks@7.6.4": - version "7.6.4" - resolved "https://registry.yarnpkg.com/@storybook/blocks/-/blocks-7.6.4.tgz#f5d0de020b886e0fd17021608df5b73e5e39ae69" - integrity sha512-iXinXXhTUBtReREP1Jifpu35DnGg7FidehjvCM8sM4E4aymfb8czdg9DdvG46T2UFUPUct36nnjIdMLWOya8Bw== +"@storybook/blocks@7.6.10": + version "7.6.10" + resolved "https://registry.yarnpkg.com/@storybook/blocks/-/blocks-7.6.10.tgz#353a5efa6a922a9a3766254f9f24cc2adad34f83" + integrity sha512-oSIukGC3yuF8pojABC/HLu5tv2axZvf60TaUs8eDg7+NiiKhzYSPoMQxs5uMrKngl+EJDB92ESgWT9vvsfvIPg== dependencies: - "@storybook/channels" "7.6.4" - "@storybook/client-logger" "7.6.4" - "@storybook/components" "7.6.4" - "@storybook/core-events" "7.6.4" + "@storybook/channels" "7.6.10" + "@storybook/client-logger" "7.6.10" + "@storybook/components" "7.6.10" + "@storybook/core-events" "7.6.10" "@storybook/csf" "^0.1.2" - "@storybook/docs-tools" "7.6.4" + "@storybook/docs-tools" "7.6.10" "@storybook/global" "^5.0.0" - "@storybook/manager-api" "7.6.4" - "@storybook/preview-api" "7.6.4" - "@storybook/theming" "7.6.4" - "@storybook/types" "7.6.4" + "@storybook/manager-api" "7.6.10" + "@storybook/preview-api" "7.6.10" + "@storybook/theming" "7.6.10" + "@storybook/types" "7.6.10" "@types/lodash" "^4.14.167" color-convert "^2.0.1" dequal "^2.0.2" @@ -3330,15 +3276,15 @@ ts-dedent "^2.0.0" util-deprecate "^1.0.2" -"@storybook/builder-manager@7.6.4": - version "7.6.4" - resolved "https://registry.yarnpkg.com/@storybook/builder-manager/-/builder-manager-7.6.4.tgz#64d4745f918bf370924b767cc3a767818fa45a47" - integrity sha512-k5+D3fXw7LdMOWd5tF7cIq8L3irrdW6/vmcEHLaJj1EXZ+DvsNCH9xSsLS+6zfrUcxug4oSfRqvF87w6Oz3DtA== +"@storybook/builder-manager@7.6.10": + version "7.6.10" + resolved "https://registry.yarnpkg.com/@storybook/builder-manager/-/builder-manager-7.6.10.tgz#fc30b19dd74e6f6ae896d8f4045552c3206c25f9" + integrity sha512-f+YrjZwohGzvfDtH8BHzqM3xW0p4vjjg9u7uzRorqUiNIAAKHpfNrZ/WvwPlPYmrpAHt4xX/nXRJae4rFSygPw== dependencies: "@fal-works/esbuild-plugin-global-externals" "^2.1.2" - "@storybook/core-common" "7.6.4" - "@storybook/manager" "7.6.4" - "@storybook/node-logger" "7.6.4" + "@storybook/core-common" "7.6.10" + "@storybook/manager" "7.6.10" + "@storybook/node-logger" "7.6.10" "@types/ejs" "^3.1.1" "@types/find-cache-dir" "^3.2.1" "@yarnpkg/esbuild-plugin-pnp" "^3.0.0-rc.10" @@ -3352,19 +3298,19 @@ process "^0.11.10" util "^0.12.4" -"@storybook/builder-vite@7.6.4": - version "7.6.4" - resolved "https://registry.yarnpkg.com/@storybook/builder-vite/-/builder-vite-7.6.4.tgz#a9baaf41731c1da63a9f70bd015975b638f95bc5" - integrity sha512-eqb3mLUfuXd4a7+46cWevQ9qH81FvHy1lrAbZGwp4bQ/Tj0YF8Ej7lKBbg7zoIwiu2zDci+BbMiaDOY1kPtILw== - dependencies: - "@storybook/channels" "7.6.4" - "@storybook/client-logger" "7.6.4" - "@storybook/core-common" "7.6.4" - "@storybook/csf-plugin" "7.6.4" - "@storybook/node-logger" "7.6.4" - "@storybook/preview" "7.6.4" - "@storybook/preview-api" "7.6.4" - "@storybook/types" "7.6.4" +"@storybook/builder-vite@7.6.10": + version "7.6.10" + resolved "https://registry.yarnpkg.com/@storybook/builder-vite/-/builder-vite-7.6.10.tgz#f8a23668a17e7473e4d19465658199c46bf731d7" + integrity sha512-qxe19axiNJVdIKj943e1ucAmADwU42fTGgMSdBzzrvfH3pSOmx2057aIxRzd8YtBRnj327eeqpgCHYIDTunMYQ== + dependencies: + "@storybook/channels" "7.6.10" + "@storybook/client-logger" "7.6.10" + "@storybook/core-common" "7.6.10" + "@storybook/csf-plugin" "7.6.10" + "@storybook/node-logger" "7.6.10" + "@storybook/preview" "7.6.10" + "@storybook/preview-api" "7.6.10" + "@storybook/types" "7.6.10" "@types/find-cache-dir" "^3.2.1" browser-assert "^1.2.1" es-module-lexer "^0.9.3" @@ -3391,6 +3337,18 @@ resolved "https://registry.yarnpkg.com/@storybook/channels/-/channels-7.0.7.tgz#3f3962be97b447752db99a78ef7beea9f94d75a4" integrity sha512-Om4ovBLNw8pVrBu83MpOKgAuGO9Dpr1Coh2qp8t64WRPkejX1mxOY9IgH723//zH3igx8LCkf9rvBvcrsyaScQ== +"@storybook/channels@7.6.10": + version "7.6.10" + resolved "https://registry.yarnpkg.com/@storybook/channels/-/channels-7.6.10.tgz#04fd2c2f0b530bb8d236f5763e8df8cb5fa7c921" + integrity sha512-ITCLhFuDBKgxetuKnWwYqMUWlU7zsfH3gEKZltTb+9/2OAWR7ez0iqU7H6bXP1ridm0DCKkt2UMWj2mmr9iQqg== + dependencies: + "@storybook/client-logger" "7.6.10" + "@storybook/core-events" "7.6.10" + "@storybook/global" "^5.0.0" + qs "^6.10.0" + telejson "^7.2.0" + tiny-invariant "^1.3.1" + "@storybook/channels@7.6.4": version "7.6.4" resolved "https://registry.yarnpkg.com/@storybook/channels/-/channels-7.6.4.tgz#d0af47f1f049c3ad77bcdefec54253f56b3b0a47" @@ -3403,23 +3361,23 @@ telejson "^7.2.0" tiny-invariant "^1.3.1" -"@storybook/cli@7.6.4": - version "7.6.4" - resolved "https://registry.yarnpkg.com/@storybook/cli/-/cli-7.6.4.tgz#3680233a32975a400c091341f8b6ae4cdb01c72f" - integrity sha512-GqvaFdkkBMJOdnrVe82XY0V3b+qFMhRNyVoTv2nqB87iMUXZHqh4Pu4LqwaJBsBpuNregvCvVOPe9LGgoOzy4A== +"@storybook/cli@7.6.10": + version "7.6.10" + resolved "https://registry.yarnpkg.com/@storybook/cli/-/cli-7.6.10.tgz#2436276c5404b166a9f795fef44bbd75826d9bfe" + integrity sha512-pK1MEseMm73OMO2OVoSz79QWX8ymxgIGM8IeZTCo9gImiVRChMNDFYcv8yPWkjuyesY8c15CoO48aR7pdA1OjQ== dependencies: "@babel/core" "^7.23.2" "@babel/preset-env" "^7.23.2" "@babel/types" "^7.23.0" "@ndelangen/get-tarball" "^3.0.7" - "@storybook/codemod" "7.6.4" - "@storybook/core-common" "7.6.4" - "@storybook/core-events" "7.6.4" - "@storybook/core-server" "7.6.4" - "@storybook/csf-tools" "7.6.4" - "@storybook/node-logger" "7.6.4" - "@storybook/telemetry" "7.6.4" - "@storybook/types" "7.6.4" + "@storybook/codemod" "7.6.10" + "@storybook/core-common" "7.6.10" + "@storybook/core-events" "7.6.10" + "@storybook/core-server" "7.6.10" + "@storybook/csf-tools" "7.6.10" + "@storybook/node-logger" "7.6.10" + "@storybook/telemetry" "7.6.10" + "@storybook/types" "7.6.10" "@types/semver" "^7.3.4" "@yarnpkg/fslib" "2.10.3" "@yarnpkg/libzip" "2.3.0" @@ -3444,19 +3402,18 @@ puppeteer-core "^2.1.1" read-pkg-up "^7.0.1" semver "^7.3.7" - simple-update-notifier "^2.0.0" strip-json-comments "^3.0.1" tempy "^1.0.1" ts-dedent "^2.0.0" util-deprecate "^1.0.2" -"@storybook/client-api@~7.6.4": - version "7.6.4" - resolved "https://registry.yarnpkg.com/@storybook/client-api/-/client-api-7.6.4.tgz#724764f2459520682a0ce9aa3315317169b821bd" - integrity sha512-EzuOUdbjK78Y4y71drpRg1DC/iuzEMB6FIey64EI8edgfs+HAmEtzH8bNfmX7gxgA82RwxO0TGuXFF0CmWOABA== +"@storybook/client-api@^7.6.10": + version "7.6.10" + resolved "https://registry.yarnpkg.com/@storybook/client-api/-/client-api-7.6.10.tgz#5fd95488cbfcaec3535a414fd94374ce0ecba063" + integrity sha512-Y9z6Uy4h3/hDAUVBEEGLLbbvnSKQJhr4Sn1wJ328PhMppcZ1+GW1iGphFBmthm+O0cun1Zevl18Y081kqiGzSQ== dependencies: - "@storybook/client-logger" "7.6.4" - "@storybook/preview-api" "7.6.4" + "@storybook/client-logger" "7.6.10" + "@storybook/preview-api" "7.6.10" "@storybook/client-logger@7.0.7": version "7.0.7" @@ -3465,6 +3422,13 @@ dependencies: "@storybook/global" "^5.0.0" +"@storybook/client-logger@7.6.10": + version "7.6.10" + resolved "https://registry.yarnpkg.com/@storybook/client-logger/-/client-logger-7.6.10.tgz#5d66feb18a21836f84b63f71cf5b3a85d669f049" + integrity sha512-U7bbpu21ntgePMz/mKM18qvCSWCUGCUlYru8mgVlXLCKqFqfTeP887+CsPEQf29aoE3cLgDrxqbRJ1wxX9kL9A== + dependencies: + "@storybook/global" "^5.0.0" + "@storybook/client-logger@7.6.4": version "7.6.4" resolved "https://registry.yarnpkg.com/@storybook/client-logger/-/client-logger-7.6.4.tgz#7533f5194903f554c297b0d327efee04c5accfbb" @@ -3472,18 +3436,18 @@ dependencies: "@storybook/global" "^5.0.0" -"@storybook/codemod@7.6.4": - version "7.6.4" - resolved "https://registry.yarnpkg.com/@storybook/codemod/-/codemod-7.6.4.tgz#e3a314ad07f9dc799bd2531bb7ffc6ab3cf15996" - integrity sha512-q4rZVOfozxzbDRH/LzuFDoIGBdXs+orAm18fi6iAx8PeMHe8J/MOXKccNV1zdkm/h7mTQowuRo45KwJHw8vX+g== +"@storybook/codemod@7.6.10": + version "7.6.10" + resolved "https://registry.yarnpkg.com/@storybook/codemod/-/codemod-7.6.10.tgz#21cc0e69df6f57d567fc27264310f820662d62fa" + integrity sha512-pzFR0nocBb94vN9QCJLC3C3dP734ZigqyPmd0ZCDj9Xce2ytfHK3v1lKB6TZWzKAZT8zztauECYxrbo4LVuagw== dependencies: "@babel/core" "^7.23.2" "@babel/preset-env" "^7.23.2" "@babel/types" "^7.23.0" "@storybook/csf" "^0.1.2" - "@storybook/csf-tools" "7.6.4" - "@storybook/node-logger" "7.6.4" - "@storybook/types" "7.6.4" + "@storybook/csf-tools" "7.6.10" + "@storybook/node-logger" "7.6.10" + "@storybook/types" "7.6.10" "@types/cross-spawn" "^6.0.2" cross-spawn "^7.0.3" globby "^11.0.2" @@ -3492,18 +3456,18 @@ prettier "^2.8.0" recast "^0.23.1" -"@storybook/components@7.6.4": - version "7.6.4" - resolved "https://registry.yarnpkg.com/@storybook/components/-/components-7.6.4.tgz#44cf5fa1b52af540157b4dee7d23a46817329ab1" - integrity sha512-K5RvEObJAnX+SbGJbkM1qrZEk+VR2cUhRCSrFnlfMwsn8/60T3qoH7U8bCXf8krDgbquhMwqev5WzDB+T1VV8g== +"@storybook/components@7.6.10": + version "7.6.10" + resolved "https://registry.yarnpkg.com/@storybook/components/-/components-7.6.10.tgz#2d1b8c66c374327663b91f65db3b1be5749a1a6b" + integrity sha512-H5hF8pxwtbt0LxV24KMMsPlbYG9Oiui3ObvAQkvGu6q62EYxRPeNSrq3GBI5XEbI33OJY9bT24cVaZx18dXqwQ== dependencies: "@radix-ui/react-select" "^1.2.2" "@radix-ui/react-toolbar" "^1.0.4" - "@storybook/client-logger" "7.6.4" + "@storybook/client-logger" "7.6.10" "@storybook/csf" "^0.1.2" "@storybook/global" "^5.0.0" - "@storybook/theming" "7.6.4" - "@storybook/types" "7.6.4" + "@storybook/theming" "7.6.10" + "@storybook/types" "7.6.10" memoizerific "^1.11.3" use-resize-observer "^9.1.0" util-deprecate "^1.0.2" @@ -3522,22 +3486,22 @@ use-resize-observer "^9.1.0" util-deprecate "^1.0.2" -"@storybook/core-client@7.6.4": - version "7.6.4" - resolved "https://registry.yarnpkg.com/@storybook/core-client/-/core-client-7.6.4.tgz#d38ddb80bbc6119017a8ba15b88c030cf03d27e0" - integrity sha512-0msqdGd+VYD1dRgAJ2StTu4d543Wveb7LVVujX3PwD/QCxmCaVUHuAoZrekM/H7jZLw546ZIbLZo0xWrADAUMw== +"@storybook/core-client@7.6.10": + version "7.6.10" + resolved "https://registry.yarnpkg.com/@storybook/core-client/-/core-client-7.6.10.tgz#cd427d7017c1f32b2e956b4eb8ea89f3424b60c9" + integrity sha512-DjnzSzSNDmZyxyg6TxugzWQwOsW+n/iWVv6sHNEvEd5STr0mjuJjIEELmv58LIr5Lsre5+LEddqHsyuLyt8ubg== dependencies: - "@storybook/client-logger" "7.6.4" - "@storybook/preview-api" "7.6.4" + "@storybook/client-logger" "7.6.10" + "@storybook/preview-api" "7.6.10" -"@storybook/core-common@7.6.4": - version "7.6.4" - resolved "https://registry.yarnpkg.com/@storybook/core-common/-/core-common-7.6.4.tgz#8ee282058ea4dd8fc0a053c30759f4bf65dab86b" - integrity sha512-qes4+mXqINu0kCgSMFjk++GZokmYjb71esId0zyJsk0pcIPkAiEjnhbSEQkMhbUfcvO1lztoaQTBW2P7Rd1tag== +"@storybook/core-common@7.6.10": + version "7.6.10" + resolved "https://registry.yarnpkg.com/@storybook/core-common/-/core-common-7.6.10.tgz#00b73761eb3c4452105a7d79b5179237a6f01b32" + integrity sha512-K3YWqjCKMnpvYsWNjOciwTH6zWbuuZzmOiipziZaVJ+sB1XYmH52Y3WGEm07TZI8AYK9DRgwA13dR/7W0nw72Q== dependencies: - "@storybook/core-events" "7.6.4" - "@storybook/node-logger" "7.6.4" - "@storybook/types" "7.6.4" + "@storybook/core-events" "7.6.10" + "@storybook/node-logger" "7.6.10" + "@storybook/types" "7.6.10" "@types/find-cache-dir" "^3.2.1" "@types/node" "^18.0.0" "@types/node-fetch" "^2.6.4" @@ -3564,6 +3528,13 @@ resolved "https://registry.yarnpkg.com/@storybook/core-events/-/core-events-7.0.7.tgz#9acb6425d0a2a3d25becc21980c2c678c6c5548e" integrity sha512-XNsR2RgaL2vBwuqsu+KA1DzGmB1UFfrAhpxhmyWTKDCniwtTLlaXgfKbqwcrOrPu/o1YswgIup/9UHepRHaf4A== +"@storybook/core-events@7.6.10": + version "7.6.10" + resolved "https://registry.yarnpkg.com/@storybook/core-events/-/core-events-7.6.10.tgz#d521cbdadebfa56caaa8815a1e132694a20f05e9" + integrity sha512-yccDH67KoROrdZbRKwxgTswFMAco5nlCyxszCDASCLygGSV2Q2e+YuywrhchQl3U6joiWi3Ps1qWu56NeNafag== + dependencies: + ts-dedent "^2.0.0" + "@storybook/core-events@7.6.4": version "7.6.4" resolved "https://registry.yarnpkg.com/@storybook/core-events/-/core-events-7.6.4.tgz#55405545dbc9ae5715654d2198ee1f1a3cc34ef7" @@ -3571,26 +3542,26 @@ dependencies: ts-dedent "^2.0.0" -"@storybook/core-server@7.6.4": - version "7.6.4" - resolved "https://registry.yarnpkg.com/@storybook/core-server/-/core-server-7.6.4.tgz#7732be1437af2affb3d7b0c5528446006a4c72a3" - integrity sha512-mXxZMpCwOhjEPPRjqrTHdiCpFdkc47f46vlgTj02SX+9xKHxslmZ2D3JG/8O4Ab9tG+bBl6lBm3RIrIzaiCu9Q== +"@storybook/core-server@7.6.10": + version "7.6.10" + resolved "https://registry.yarnpkg.com/@storybook/core-server/-/core-server-7.6.10.tgz#53bf43b8b3c999c87196774a0b92e2e10a434e4c" + integrity sha512-2icnqJkn3vwq0eJPP0rNaHd7IOvxYf5q4lSVl2AWTxo/Ae19KhokI6j/2vvS2XQJMGQszwshlIwrZUNsj5p0yw== dependencies: "@aw-web-design/x-default-browser" "1.4.126" "@discoveryjs/json-ext" "^0.5.3" - "@storybook/builder-manager" "7.6.4" - "@storybook/channels" "7.6.4" - "@storybook/core-common" "7.6.4" - "@storybook/core-events" "7.6.4" + "@storybook/builder-manager" "7.6.10" + "@storybook/channels" "7.6.10" + "@storybook/core-common" "7.6.10" + "@storybook/core-events" "7.6.10" "@storybook/csf" "^0.1.2" - "@storybook/csf-tools" "7.6.4" + "@storybook/csf-tools" "7.6.10" "@storybook/docs-mdx" "^0.1.0" "@storybook/global" "^5.0.0" - "@storybook/manager" "7.6.4" - "@storybook/node-logger" "7.6.4" - "@storybook/preview-api" "7.6.4" - "@storybook/telemetry" "7.6.4" - "@storybook/types" "7.6.4" + "@storybook/manager" "7.6.10" + "@storybook/node-logger" "7.6.10" + "@storybook/preview-api" "7.6.10" + "@storybook/telemetry" "7.6.10" + "@storybook/types" "7.6.10" "@types/detect-port" "^1.3.0" "@types/node" "^18.0.0" "@types/pretty-hrtime" "^1.0.0" @@ -3618,25 +3589,25 @@ watchpack "^2.2.0" ws "^8.2.3" -"@storybook/csf-plugin@7.6.4": - version "7.6.4" - resolved "https://registry.yarnpkg.com/@storybook/csf-plugin/-/csf-plugin-7.6.4.tgz#5d18cffc1c92dfd12d8571b2ba9d1456de58d8b2" - integrity sha512-7g9p8s2ITX+Z9iThK5CehPhJOcusVN7JcUEEW+gVF5PlYT+uk/x+66gmQno+scQuNkV9+8UJD6RLFjP+zg2uCA== +"@storybook/csf-plugin@7.6.10": + version "7.6.10" + resolved "https://registry.yarnpkg.com/@storybook/csf-plugin/-/csf-plugin-7.6.10.tgz#479cffe04c68a87f60589a6891a306805c758437" + integrity sha512-Sc+zZg/BnPH2X28tthNaQBnDiFfO0QmfjVoOx0fGYM9SvY3P5ehzWwp5hMRBim6a/twOTzePADtqYL+t6GMqqg== dependencies: - "@storybook/csf-tools" "7.6.4" + "@storybook/csf-tools" "7.6.10" unplugin "^1.3.1" -"@storybook/csf-tools@7.6.4": - version "7.6.4" - resolved "https://registry.yarnpkg.com/@storybook/csf-tools/-/csf-tools-7.6.4.tgz#4c1ef7a79ecd6f96d7fc1cb092cf2ed942cd65e2" - integrity sha512-6sLayuhgReIK3/QauNj5BW4o4ZfEMJmKf+EWANPEM/xEOXXqrog6Un8sjtBuJS9N1DwyhHY6xfkEiPAwdttwqw== +"@storybook/csf-tools@7.6.10": + version "7.6.10" + resolved "https://registry.yarnpkg.com/@storybook/csf-tools/-/csf-tools-7.6.10.tgz#320638f64e2e14cf539dd55188f676fd82789be5" + integrity sha512-TnDNAwIALcN6SA4l00Cb67G02XMOrYU38bIpFJk5VMDX2dvgPjUtJNBuLmEbybGcOt7nPyyFIHzKcY5FCVGoWA== dependencies: "@babel/generator" "^7.23.0" "@babel/parser" "^7.23.0" "@babel/traverse" "^7.23.2" "@babel/types" "^7.23.0" "@storybook/csf" "^0.1.2" - "@storybook/types" "7.6.4" + "@storybook/types" "7.6.10" fs-extra "^11.1.0" recast "^0.23.1" ts-dedent "^2.0.0" @@ -3667,14 +3638,14 @@ resolved "https://registry.yarnpkg.com/@storybook/docs-mdx/-/docs-mdx-0.1.0.tgz#33ba0e39d1461caf048b57db354b2cc410705316" integrity sha512-JDaBR9lwVY4eSH5W8EGHrhODjygPd6QImRbwjAuJNEnY0Vw4ie3bPkeGfnacB3OBW6u/agqPv2aRlR46JcAQLg== -"@storybook/docs-tools@7.6.4": - version "7.6.4" - resolved "https://registry.yarnpkg.com/@storybook/docs-tools/-/docs-tools-7.6.4.tgz#504b8654f348a109b69cc77cdab556f0bf9c2304" - integrity sha512-2eGam43aD7O3cocA72Z63kRi7t/ziMSpst0qB218QwBWAeZjT4EYDh8V6j/Xhv6zVQL3msW7AglrQP5kCKPvPA== +"@storybook/docs-tools@7.6.10": + version "7.6.10" + resolved "https://registry.yarnpkg.com/@storybook/docs-tools/-/docs-tools-7.6.10.tgz#90ce6bcf468b8d0a479fb75e9a6ff87f482095dc" + integrity sha512-UgbikducoXzqQHf2TozO0f2rshaeBNnShVbL5Ai4oW7pDymBmrfzdjGbF/milO7yxNKcoIByeoNmu384eBamgQ== dependencies: - "@storybook/core-common" "7.6.4" - "@storybook/preview-api" "7.6.4" - "@storybook/types" "7.6.4" + "@storybook/core-common" "7.6.10" + "@storybook/preview-api" "7.6.10" + "@storybook/types" "7.6.10" "@types/doctrine" "^0.0.3" assert "^2.1.0" doctrine "^3.0.0" @@ -3706,7 +3677,27 @@ telejson "^7.0.3" ts-dedent "^2.0.0" -"@storybook/manager-api@7.6.4", "@storybook/manager-api@^7.0.0": +"@storybook/manager-api@7.6.10": + version "7.6.10" + resolved "https://registry.yarnpkg.com/@storybook/manager-api/-/manager-api-7.6.10.tgz#0c2932f42bb97de8fb25240844fcf64474fc8905" + integrity sha512-8eGVpRlpunuFScDtc7nxpPJf/4kJBAAZlNdlhmX09j8M3voX6GpcxabBamSEX5pXZqhwxQCshD4IbqBmjvadlw== + dependencies: + "@storybook/channels" "7.6.10" + "@storybook/client-logger" "7.6.10" + "@storybook/core-events" "7.6.10" + "@storybook/csf" "^0.1.2" + "@storybook/global" "^5.0.0" + "@storybook/router" "7.6.10" + "@storybook/theming" "7.6.10" + "@storybook/types" "7.6.10" + dequal "^2.0.2" + lodash "^4.17.21" + memoizerific "^1.11.3" + store2 "^2.14.2" + telejson "^7.2.0" + ts-dedent "^2.0.0" + +"@storybook/manager-api@^7.0.0": version "7.6.4" resolved "https://registry.yarnpkg.com/@storybook/manager-api/-/manager-api-7.6.4.tgz#edf5d553a78987ad8602700b7200391776010f48" integrity sha512-RFb/iaBJfXygSgXkINPRq8dXu7AxBicTGX7MxqKXbz5FU7ANwV7abH6ONBYURkSDOH9//TQhRlVkF5u8zWg3bw== @@ -3727,25 +3718,25 @@ telejson "^7.2.0" ts-dedent "^2.0.0" -"@storybook/manager@7.6.4": - version "7.6.4" - resolved "https://registry.yarnpkg.com/@storybook/manager/-/manager-7.6.4.tgz#11ea1ce8f37a069c96e594a716957f0d6c7690f3" - integrity sha512-Ug2ejfKgKre8h/RJbkumukwAA44TbvTPEjDcJmyFdAI+kHYhOYdKPEC2UNmVYz8/4HjwMTJQ3M7t/esK8HHY4A== +"@storybook/manager@7.6.10": + version "7.6.10" + resolved "https://registry.yarnpkg.com/@storybook/manager/-/manager-7.6.10.tgz#eb1b71c802fbf04353f3bf017dfb102eb0db217e" + integrity sha512-Co3sLCbNYY6O4iH2ggmRDLCPWLj03JE5s/DOG8OVoXc6vBwTc/Qgiyrsxxp6BHQnPpM0mxL6aKAxE3UjsW/Nog== "@storybook/mdx2-csf@^1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@storybook/mdx2-csf/-/mdx2-csf-1.0.0.tgz#ce4b2e44c9082bf382db835eef611b0097b7d771" integrity sha512-dBAnEL4HfxxJmv7LdEYUoZlQbWj9APZNIbOaq0tgF8XkxiIbzqvgB0jhL/9UOrysSDbQWBiCRTu2wOVxedGfmw== -"@storybook/node-logger@7.6.4": - version "7.6.4" - resolved "https://registry.yarnpkg.com/@storybook/node-logger/-/node-logger-7.6.4.tgz#e471ee117bbd329522113dd98e5a42e83e259f9c" - integrity sha512-GDkEnnDj4Op+PExs8ZY/P6ox3wg453CdEIaR8PR9TxF/H/T2fBL6puzma3hN2CMam6yzfAL8U+VeIIDLQ5BZdQ== +"@storybook/node-logger@7.6.10": + version "7.6.10" + resolved "https://registry.yarnpkg.com/@storybook/node-logger/-/node-logger-7.6.10.tgz#d4c52d04384d2728d6610fb0afff6eb1feb50fd4" + integrity sha512-ZBuqrv4bjJzKXyfRGFkVIi+z6ekn6rOPoQao4KmsfLNQAUUsEdR8Baw/zMnnU417zw5dSEaZdpuwx75SCQAeOA== -"@storybook/postinstall@7.6.4": - version "7.6.4" - resolved "https://registry.yarnpkg.com/@storybook/postinstall/-/postinstall-7.6.4.tgz#baefcec351ac79ee711ad27b65bf1ec28e545da6" - integrity sha512-7uoB82hSzlFSdDMS3hKQD+AaeSvPit/fAMvXCBxn0/D0UGJUZcq4M9JcKBwEHkZJcbuDROgOTJ6TUeXi/FWO0w== +"@storybook/postinstall@7.6.10": + version "7.6.10" + resolved "https://registry.yarnpkg.com/@storybook/postinstall/-/postinstall-7.6.10.tgz#9e81c54b1f23f71a59a6db7ee8a4d5ac40852d17" + integrity sha512-SMdXtednPCy3+SRJ7oN1OPN1oVFhj3ih+ChOEX8/kZ5J3nfmV3wLPtsZvFGUCf0KWQEP1xL+1Urv48mzMKcV/w== "@storybook/preview-api@7.0.7": version "7.0.7" @@ -3768,17 +3759,17 @@ ts-dedent "^2.0.0" util-deprecate "^1.0.2" -"@storybook/preview-api@7.6.4": - version "7.6.4" - resolved "https://registry.yarnpkg.com/@storybook/preview-api/-/preview-api-7.6.4.tgz#df103fbdfada5ceee540f6ea68001c0d2c718113" - integrity sha512-KhisNdQX5NdfAln+spLU4B82d804GJQp/CnI5M1mm/taTnjvMgs/wTH9AmR89OPoq+tFZVW0vhy2zgPS3ar71A== +"@storybook/preview-api@7.6.10": + version "7.6.10" + resolved "https://registry.yarnpkg.com/@storybook/preview-api/-/preview-api-7.6.10.tgz#b8d5a4f897745fc28f0ae75f7e0e9278b0e4a50a" + integrity sha512-5A3etoIwZCx05yuv3KSTv1wynN4SR4rrzaIs/CTBp3BC4q1RBL+Or/tClk0IJPXQMlx/4Y134GtNIBbkiDofpw== dependencies: - "@storybook/channels" "7.6.4" - "@storybook/client-logger" "7.6.4" - "@storybook/core-events" "7.6.4" + "@storybook/channels" "7.6.10" + "@storybook/client-logger" "7.6.10" + "@storybook/core-events" "7.6.10" "@storybook/csf" "^0.1.2" "@storybook/global" "^5.0.0" - "@storybook/types" "7.6.4" + "@storybook/types" "7.6.10" "@types/qs" "^6.9.5" dequal "^2.0.2" lodash "^4.17.21" @@ -3788,41 +3779,41 @@ ts-dedent "^2.0.0" util-deprecate "^1.0.2" -"@storybook/preview@7.6.4": - version "7.6.4" - resolved "https://registry.yarnpkg.com/@storybook/preview/-/preview-7.6.4.tgz#36cca1d10fd3729f1a68789d95bfc400e33aa616" - integrity sha512-p9xIvNkgXgTpSRphOMV9KpIiNdkymH61jBg3B0XyoF6IfM1S2/mQGvC89lCVz1dMGk2SrH4g87/WcOapkU5ArA== +"@storybook/preview@7.6.10": + version "7.6.10" + resolved "https://registry.yarnpkg.com/@storybook/preview/-/preview-7.6.10.tgz#895053c97f7e09141c6321fa42390fa8af377bef" + integrity sha512-F07BzVXTD3byq+KTWtvsw3pUu3fQbyiBNLFr2CnfU4XSdLKja5lDt8VqDQq70TayVQOf5qfUTzRd4M6pQkjw1w== -"@storybook/react-dom-shim@7.6.4": - version "7.6.4" - resolved "https://registry.yarnpkg.com/@storybook/react-dom-shim/-/react-dom-shim-7.6.4.tgz#d1f5440c8f1ccd95abcc01117872ce46d0ab9dc3" - integrity sha512-wGJfomlDEBnowNmhmumWDu/AcUInxSoPqUUJPgk2f5oL0EW17fR9fDP/juG3XOEdieMDM0jDX48GML7lyvL2fg== +"@storybook/react-dom-shim@7.6.10": + version "7.6.10" + resolved "https://registry.yarnpkg.com/@storybook/react-dom-shim/-/react-dom-shim-7.6.10.tgz#d16df5d65a51ed66df92430d8f51d50bd177f2c2" + integrity sha512-M+N/h6ximacaFdIDjMN2waNoWwApeVYTpFeoDppiFTvdBTXChyIuiPgYX9QSg7gDz92OaA52myGOot4wGvXVzg== -"@storybook/react-vite@^7.6.4": - version "7.6.4" - resolved "https://registry.yarnpkg.com/@storybook/react-vite/-/react-vite-7.6.4.tgz#a319711d32376909a0abaa44f41965e025e2f948" - integrity sha512-1NYzCJRO6k/ZyoMzpu1FQiaUaiLNjAvTAB1x3HE7oY/tEIT8kGpzXGYH++LJVWvyP/5dSWlUnRSy2rJvySraiw== +"@storybook/react-vite@^7.6.10": + version "7.6.10" + resolved "https://registry.yarnpkg.com/@storybook/react-vite/-/react-vite-7.6.10.tgz#d69e1e8c9043bbc5e856bb09f07bb3a8b361fd93" + integrity sha512-YE2+J1wy8nO+c6Nv/hBMu91Edew3K184L1KSnfoZV8vtq2074k1Me/8pfe0QNuq631AncpfCYNb37yBAXQ/80w== dependencies: "@joshwooding/vite-plugin-react-docgen-typescript" "0.3.0" "@rollup/pluginutils" "^5.0.2" - "@storybook/builder-vite" "7.6.4" - "@storybook/react" "7.6.4" + "@storybook/builder-vite" "7.6.10" + "@storybook/react" "7.6.10" "@vitejs/plugin-react" "^3.0.1" magic-string "^0.30.0" react-docgen "^7.0.0" -"@storybook/react@7.6.4", "@storybook/react@~7.6.4": - version "7.6.4" - resolved "https://registry.yarnpkg.com/@storybook/react/-/react-7.6.4.tgz#b17c451eb1240399d75c45ccb90a145ab1de44ac" - integrity sha512-XYRP+eylH3JqkCuziwtQGY5vOCeDreOibRYJmj5na6k4QbURjGVB44WCIW04gWVlmBXM9SqLAmserUi3HP890Q== +"@storybook/react@7.6.10", "@storybook/react@^7.6.10": + version "7.6.10" + resolved "https://registry.yarnpkg.com/@storybook/react/-/react-7.6.10.tgz#aca5c446f43de75981f19d112a8a04d7abd0a03d" + integrity sha512-wwBn1cg2uZWW4peqqBjjU7XGmFq8HdkVUtWwh6dpfgmlY1Aopi+vPgZt7pY9KkWcTOq5+DerMdSfwxukpc3ajQ== dependencies: - "@storybook/client-logger" "7.6.4" - "@storybook/core-client" "7.6.4" - "@storybook/docs-tools" "7.6.4" + "@storybook/client-logger" "7.6.10" + "@storybook/core-client" "7.6.10" + "@storybook/docs-tools" "7.6.10" "@storybook/global" "^5.0.0" - "@storybook/preview-api" "7.6.4" - "@storybook/react-dom-shim" "7.6.4" - "@storybook/types" "7.6.4" + "@storybook/preview-api" "7.6.10" + "@storybook/react-dom-shim" "7.6.10" + "@storybook/types" "7.6.10" "@types/escodegen" "^0.0.6" "@types/estree" "^0.0.51" "@types/node" "^18.0.0" @@ -3847,6 +3838,15 @@ memoizerific "^1.11.3" qs "^6.10.0" +"@storybook/router@7.6.10": + version "7.6.10" + resolved "https://registry.yarnpkg.com/@storybook/router/-/router-7.6.10.tgz#b1f2c550eeb9f7146eefa33c5460e4149a62d721" + integrity sha512-G/H4Jn2+y8PDe8Zbq4DVxF/TPn0/goSItdILts39JENucHiuGBCjKjSWGBe1rkwKi1tUbB3yhxJVrLagxFEPpQ== + dependencies: + "@storybook/client-logger" "7.6.10" + memoizerific "^1.11.3" + qs "^6.10.0" + "@storybook/router@7.6.4": version "7.6.4" resolved "https://registry.yarnpkg.com/@storybook/router/-/router-7.6.4.tgz#13112bd9d6709bebe4072d923e4921b660f8f00c" @@ -3856,25 +3856,25 @@ memoizerific "^1.11.3" qs "^6.10.0" -"@storybook/source-loader@7.6.4": - version "7.6.4" - resolved "https://registry.yarnpkg.com/@storybook/source-loader/-/source-loader-7.6.4.tgz#ef930e2dd947f45cbfaf845fa21035d82ac25c8d" - integrity sha512-1wb/3bVpJZ/3r3qUrLK8jb0kLuvwjNi5T1kci5huREdc1TrIxZXoPw9EiyjcMCZzCURkoj7euNLrLHGyzdBTLg== +"@storybook/source-loader@7.6.10": + version "7.6.10" + resolved "https://registry.yarnpkg.com/@storybook/source-loader/-/source-loader-7.6.10.tgz#fe214f6323a27c14b85b6beb573a9ddb38d8cb35" + integrity sha512-S3nOWyj+sdpsqJqKGIN3DKE1q+Q0KYxEyPlPCawMFazozUH7tOodTIqmHBqJZCSNqdC4M1S/qcL8vpP4PfXhuA== dependencies: "@storybook/csf" "^0.1.2" - "@storybook/types" "7.6.4" + "@storybook/types" "7.6.10" estraverse "^5.2.0" lodash "^4.17.21" prettier "^2.8.0" -"@storybook/telemetry@7.6.4": - version "7.6.4" - resolved "https://registry.yarnpkg.com/@storybook/telemetry/-/telemetry-7.6.4.tgz#b28e8f7abf73b4900d83dfa68a988b6f3df9c24a" - integrity sha512-Q4QpvcgloHUEqC9PGo7tgqkUH91/PjX+74/0Hi9orLo8QmLMgdYS5fweFwgSKoTwDGNg2PaHp/jqvhhw7UmnJA== +"@storybook/telemetry@7.6.10": + version "7.6.10" + resolved "https://registry.yarnpkg.com/@storybook/telemetry/-/telemetry-7.6.10.tgz#31c0edfb9c7005cf9b5922e51ca896218e3d81ea" + integrity sha512-p3mOSUtIyy2tF1z6pQXxNh1JzYFcAm97nUgkwLzF07GfEdVAPM+ftRSLFbD93zVvLEkmLTlsTiiKaDvOY/lQWg== dependencies: - "@storybook/client-logger" "7.6.4" - "@storybook/core-common" "7.6.4" - "@storybook/csf-tools" "7.6.4" + "@storybook/client-logger" "7.6.10" + "@storybook/core-common" "7.6.10" + "@storybook/csf-tools" "7.6.10" chalk "^4.1.0" detect-package-manager "^2.0.1" fetch-retry "^5.0.2" @@ -3891,7 +3891,17 @@ "@storybook/global" "^5.0.0" memoizerific "^1.11.3" -"@storybook/theming@7.6.4", "@storybook/theming@~7.6.4": +"@storybook/theming@7.6.10", "@storybook/theming@^7.6.10": + version "7.6.10" + resolved "https://registry.yarnpkg.com/@storybook/theming/-/theming-7.6.10.tgz#c09d66d19f5756964cc89b1f94051545fc4aaea7" + integrity sha512-f5tuy7yV3TOP3fIboSqpgLHy0wKayAw/M8HxX0jVET4Z4fWlFK0BiHJabQ+XEdAfQM97XhPFHB2IPbwsqhCEcQ== + dependencies: + "@emotion/use-insertion-effect-with-fallbacks" "^1.0.0" + "@storybook/client-logger" "7.6.10" + "@storybook/global" "^5.0.0" + memoizerific "^1.11.3" + +"@storybook/theming@7.6.4": version "7.6.4" resolved "https://registry.yarnpkg.com/@storybook/theming/-/theming-7.6.4.tgz#cc81d0aee5fc80fe383cb6790e3a0e2ad80d2185" integrity sha512-Z/dcC5EpkIXelYCkt9ojnX6D7qGOng8YHxV/OWlVE9TrEGYVGPOEfwQryR0RhmGpDha1TYESLYrsDb4A8nJ1EA== @@ -3911,6 +3921,16 @@ "@types/express" "^4.7.0" file-system-cache "^2.0.0" +"@storybook/types@7.6.10": + version "7.6.10" + resolved "https://registry.yarnpkg.com/@storybook/types/-/types-7.6.10.tgz#20cfb2dfeba2ecf54721de131276041d073fe42e" + integrity sha512-hcS2HloJblaMpCAj2axgGV+53kgSRYPT0a1PG1IHsZaYQILfHSMmBqM8XzXXYTsgf9250kz3dqFX1l0n3EqMlQ== + dependencies: + "@storybook/channels" "7.6.10" + "@types/babel__core" "^7.0.0" + "@types/express" "^4.7.0" + file-system-cache "2.3.0" + "@storybook/types@7.6.4": version "7.6.4" resolved "https://registry.yarnpkg.com/@storybook/types/-/types-7.6.4.tgz#3bb50b46286cc83484848c3c450c32cc2071eb21" @@ -4406,6 +4426,13 @@ resolved "https://registry.yarnpkg.com/@types/d3-timer/-/d3-timer-3.0.2.tgz#70bbda77dc23aa727413e22e214afa3f0e852f70" integrity sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw== +"@types/debug@^4.0.0": + version "4.1.12" + resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.12.tgz#a155f21690871953410df4b6b6f53187f0500917" + integrity sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ== + dependencies: + "@types/ms" "*" + "@types/debug@^4.1.7": version "4.1.10" resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.10.tgz#f23148a6eb771a34c466a4fc28379d8101e84494" @@ -4641,6 +4668,13 @@ resolved "https://registry.yarnpkg.com/@types/md5/-/md5-2.3.2.tgz#529bb3f8a7e9e9f621094eb76a443f585d882528" integrity sha512-v+JFDu96+UYJ3/UWzB0mEglIS//MZXgRaJ4ubUPwOM0gvLc/kcQ3TWNYwENEK7/EcXGQVrW8h/XqednSjBd/Og== +"@types/mdast@^3.0.0": + version "3.0.15" + resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-3.0.15.tgz#49c524a263f30ffa28b71ae282f813ed000ab9f5" + integrity sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ== + dependencies: + "@types/unist" "^2" + "@types/mdurl@*": version "1.0.2" resolved "https://registry.yarnpkg.com/@types/mdurl/-/mdurl-1.0.2.tgz#e2ce9d83a613bacf284c7be7d491945e39e1f8e9" @@ -4980,6 +5014,11 @@ resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.4.tgz#cf2f0c7c51b985b6afecea73eb2cd65421ecb717" integrity sha512-95Sfz4nvMAb0Nl9DTxN3j64adfwfbBPEYq14VN7zT5J5O2M9V6iZMIIQU1U+pJyl9agHYHNCqhCXgyEtIRRa5A== +"@types/unist@^2": + version "2.0.10" + resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.10.tgz#04ffa7f406ab628f7f7e97ca23e290cd8ab15efc" + integrity sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA== + "@types/unist@^2.0.0": version "2.0.6" resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.6.tgz#250a7b16c3b91f672a24552ec64678eeb1d3a08d" @@ -5968,6 +6007,11 @@ babel-plugin-syntax-jsx@^6.18.0: resolved "https://registry.yarnpkg.com/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz#0af32a9a6e13ca7a3fd5069e62d7b0f58d0d8946" integrity sha512-qrPaCSo9c8RHNRHIotaufGbuOBN8rtdC4QrrFFc43vyWCCz7Kl7GL1PGaXtMGQZUXrkCjNEgxDfmAuAabr/rlw== +bail@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/bail/-/bail-2.0.2.tgz#d26f5cd8fe5d6f832a31517b9f7c356040ba6d5d" + integrity sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw== + balanced-match@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" @@ -6313,6 +6357,11 @@ caseless@~0.12.0: resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" integrity sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw== +ccount@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/ccount/-/ccount-2.0.1.tgz#17a3bf82302e0870d6da43a01311a8bc02a3ecf5" + integrity sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg== + chai-string@^1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/chai-string/-/chai-string-1.5.0.tgz#0bdb2d8a5f1dbe90bc78ec493c1c1c180dd4d3d2" @@ -6378,6 +6427,11 @@ change-emitter@^0.1.2: resolved "https://registry.yarnpkg.com/change-emitter/-/change-emitter-0.1.6.tgz#e8b2fe3d7f1ab7d69a32199aff91ea6931409515" integrity sha512-YXzt1cQ4a2jqazhcuSWEOc1K2q8g9H6eWNsyZgi640LDzRWVQ2eDe+Y/kVdftH+vYdPF2rgDb3dLdpxE1jvAxw== +character-entities@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/character-entities/-/character-entities-2.0.2.tgz#2d09c2e72cd9523076ccb21157dff66ad43fcc22" + integrity sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ== + chardet@^0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e" @@ -7144,7 +7198,7 @@ debug@2.6.9, debug@^2.6.9: dependencies: ms "2.0.0" -debug@4, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4: +debug@4, debug@^4.0.0, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4: version "4.3.4" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== @@ -7173,6 +7227,13 @@ decimal.js@^10.4.3: resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.4.3.tgz#1044092884d245d1b7f65725fa4ad4c6f781cc23" integrity sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA== +decode-named-character-reference@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz#daabac9690874c394c81e4162a0304b35d824f0e" + integrity sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg== + dependencies: + character-entities "^2.0.0" + decode-uri-component@^0.2.0: version "0.2.2" resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.2.tgz#e69dbe25d37941171dd540e024c444cd5188e1e9" @@ -7285,7 +7346,7 @@ depd@2.0.0: resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== -dequal@^2.0.2: +dequal@^2.0.0, dequal@^2.0.2: version "2.0.3" resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be" integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== @@ -7340,6 +7401,11 @@ diff@^4.0.1: resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== +diff@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/diff/-/diff-5.1.0.tgz#bc52d298c5ea8df9194800224445ed43ffc87e40" + integrity sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw== + dir-glob@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f" @@ -7508,6 +7574,13 @@ encodeurl@~1.0.2: resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w== +encoding@^0.1.11: + version "0.1.13" + resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.13.tgz#56574afdd791f54a8e9b2785c0582a2d26210fa9" + integrity sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A== + dependencies: + iconv-lite "^0.6.2" + end-of-stream@^1.0.0, end-of-stream@^1.1.0, end-of-stream@^1.4.1: version "1.4.4" resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" @@ -7795,6 +7868,11 @@ escape-string-regexp@^4.0.0: resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== +escape-string-regexp@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz#4683126b500b61762f2dbebace1806e8be31b1c8" + integrity sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw== + escodegen@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-2.1.0.tgz#ba93bbb7a43986d29d6041f99f5262da773e2e17" @@ -8921,13 +8999,20 @@ github-slugger@^1.0.0: resolved "https://registry.yarnpkg.com/github-slugger/-/github-slugger-1.5.0.tgz#17891bbc73232051474d68bd867a34625c955f7d" integrity sha512-wIh+gKBI9Nshz2o46B0B3f5k/W+WI9ZAv6y5Dn5WJ5SK1t0TnDimB4WE5rmTD05ZAIn8HALCZVmCsvj0w0v0lw== -glob-parent@^5.0.0, glob-parent@^5.1.2, glob-parent@^6.0.2, glob-parent@~5.1.2: +glob-parent@^5.0.0, glob-parent@^5.1.2, glob-parent@~5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== dependencies: is-glob "^4.0.1" +glob-parent@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3" + integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== + dependencies: + is-glob "^4.0.3" + glob-promise@^4.2.0: version "4.2.2" resolved "https://registry.yarnpkg.com/glob-promise/-/glob-promise-4.2.2.tgz#15f44bcba0e14219cd93af36da6bb905ff007877" @@ -9207,12 +9292,10 @@ hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0, hoist-non-react- dependencies: react-is "^16.7.0" -hosted-git-info@^2.1.4, hosted-git-info@^5.0.0: - version "5.2.1" - resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-5.2.1.tgz#0ba1c97178ef91f3ab30842ae63d6a272341156f" - integrity sha512-xIcQYMnhcx2Nr4JTjsFmwwnr9vldugPy9uVm0o87bjqqWMv9GaqsTeT+i99wTl0mk1uLxJtHxLb8kymqTENQsw== - dependencies: - lru-cache "^7.5.1" +hosted-git-info@^2.1.4: + version "2.8.9" + resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9" + integrity sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw== html-element-map@^1.2.0: version "1.3.1" @@ -9356,7 +9439,7 @@ iconv-lite@0.4.24, iconv-lite@^0.4.24: dependencies: safer-buffer ">= 2.1.2 < 3" -iconv-lite@0.6.3: +iconv-lite@0.6.3, iconv-lite@^0.6.2: version "0.6.3" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== @@ -9567,7 +9650,7 @@ is-boolean-object@^1.0.1, is-boolean-object@^1.1.0: call-bind "^1.0.2" has-tostringtag "^1.0.0" -is-buffer@^2.0.5: +is-buffer@^2.0.0, is-buffer@^2.0.5: version "2.0.5" resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-2.0.5.tgz#ebc252e400d22ff8d77fa09888821a24a658c191" integrity sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ== @@ -9736,6 +9819,11 @@ is-path-inside@^3.0.2, is-path-inside@^3.0.3: resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283" integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ== +is-plain-obj@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-4.1.0.tgz#d65025edec3657ce032fd7db63c97883eaed71f0" + integrity sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg== + is-plain-object@5.0.0, is-plain-object@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-5.0.0.tgz#4427f50ab3429e9025ea7d52e9043a9ef4159344" @@ -9778,7 +9866,7 @@ is-shared-array-buffer@^1.0.2: dependencies: call-bind "^1.0.2" -is-stream@^1.1.0: +is-stream@^1.0.1, is-stream@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" integrity sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ== @@ -10305,7 +10393,7 @@ junit2json@^3.1.4: xml2js "0.6.2" yargs "17.7.2" -kind-of@^6.0.2, kind-of@^6.0.3: +kind-of@^6.0.2: version "6.0.3" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw== @@ -10322,6 +10410,11 @@ kleur@^3.0.3: resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e" integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w== +kleur@^4.0.3: + version "4.1.5" + resolved "https://registry.yarnpkg.com/kleur/-/kleur-4.1.5.tgz#95106101795f7050c6c650f350c683febddb1780" + integrity sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ== + language-subtag-registry@~0.3.2: version "0.3.22" resolved "https://registry.yarnpkg.com/language-subtag-registry/-/language-subtag-registry-0.3.22.tgz#2e1500861b2e457eba7e7ae86877cbd08fa1fd1d" @@ -10604,6 +10697,11 @@ logic-query-parser@^0.0.5: resolved "https://registry.yarnpkg.com/logic-query-parser/-/logic-query-parser-0.0.5.tgz#56edf7c012f594c8236fd5175079ef5da1bea93c" integrity sha512-I4CZwF+dtnBYd1pKUCTgeyvDZC0ymElwmZAs77l1ebZsFZzmUkUQtdctZtMDB84diaRlaKjEpB+g26og//+fug== +longest-streak@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/longest-streak/-/longest-streak-3.1.0.tgz#62fa67cd958742a1574af9f39866364102d90cd4" + integrity sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g== + loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.1, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" @@ -10632,11 +10730,6 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" -lru-cache@^7.5.1: - version "7.18.3" - resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.18.3.tgz#f793896e0fd0e954a59dfdd82f0773808df6aa89" - integrity sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA== - "lru-cache@^9.1.1 || ^10.0.0": version "10.0.0" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.0.0.tgz#b9e2a6a72a129d81ab317202d93c7691df727e61" @@ -10737,6 +10830,11 @@ markdown-it@^12.3.2: mdurl "^1.0.1" uc.micro "^1.0.5" +markdown-table@^3.0.0: + version "3.0.3" + resolved "https://registry.yarnpkg.com/markdown-table/-/markdown-table-3.0.3.tgz#e6331d30e493127e031dd385488b5bd326e4a6bd" + integrity sha512-Z1NL3Tb1M9wH4XESsCDEksWoKTdlUafKc4pt0GRwjUyXaCFZ+dc3g2erqB6zm3szA2IUSi7VnPI+o/9jnxh9hw== + markdown-to-jsx@^7.1.8: version "7.1.9" resolved "https://registry.yarnpkg.com/markdown-to-jsx/-/markdown-to-jsx-7.1.9.tgz#1ffae0cda07c189163d273bd57a5b8f8f8745586" @@ -10766,11 +10864,126 @@ mdast-util-definitions@^4.0.0: dependencies: unist-util-visit "^2.0.0" +mdast-util-find-and-replace@^2.0.0: + version "2.2.2" + resolved "https://registry.yarnpkg.com/mdast-util-find-and-replace/-/mdast-util-find-and-replace-2.2.2.tgz#cc2b774f7f3630da4bd592f61966fecade8b99b1" + integrity sha512-MTtdFRz/eMDHXzeK6W3dO7mXUlF82Gom4y0oOgvHhh/HXZAGvIQDUvQ0SuUx+j2tv44b8xTHOm8K/9OoRFnXKw== + dependencies: + "@types/mdast" "^3.0.0" + escape-string-regexp "^5.0.0" + unist-util-is "^5.0.0" + unist-util-visit-parents "^5.0.0" + +mdast-util-from-markdown@^1.0.0: + version "1.3.1" + resolved "https://registry.yarnpkg.com/mdast-util-from-markdown/-/mdast-util-from-markdown-1.3.1.tgz#9421a5a247f10d31d2faed2a30df5ec89ceafcf0" + integrity sha512-4xTO/M8c82qBcnQc1tgpNtubGUW/Y1tBQ1B0i5CtSoelOLKFYlElIr3bvgREYYO5iRqbMY1YuqZng0GVOI8Qww== + dependencies: + "@types/mdast" "^3.0.0" + "@types/unist" "^2.0.0" + decode-named-character-reference "^1.0.0" + mdast-util-to-string "^3.1.0" + micromark "^3.0.0" + micromark-util-decode-numeric-character-reference "^1.0.0" + micromark-util-decode-string "^1.0.0" + micromark-util-normalize-identifier "^1.0.0" + micromark-util-symbol "^1.0.0" + micromark-util-types "^1.0.0" + unist-util-stringify-position "^3.0.0" + uvu "^0.5.0" + +mdast-util-gfm-autolink-literal@^1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-1.0.3.tgz#67a13abe813d7eba350453a5333ae1bc0ec05c06" + integrity sha512-My8KJ57FYEy2W2LyNom4n3E7hKTuQk/0SES0u16tjA9Z3oFkF4RrC/hPAPgjlSpezsOvI8ObcXcElo92wn5IGA== + dependencies: + "@types/mdast" "^3.0.0" + ccount "^2.0.0" + mdast-util-find-and-replace "^2.0.0" + micromark-util-character "^1.0.0" + +mdast-util-gfm-footnote@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-1.0.2.tgz#ce5e49b639c44de68d5bf5399877a14d5020424e" + integrity sha512-56D19KOGbE00uKVj3sgIykpwKL179QsVFwx/DCW0u/0+URsryacI4MAdNJl0dh+u2PSsD9FtxPFbHCzJ78qJFQ== + dependencies: + "@types/mdast" "^3.0.0" + mdast-util-to-markdown "^1.3.0" + micromark-util-normalize-identifier "^1.0.0" + +mdast-util-gfm-strikethrough@^1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-1.0.3.tgz#5470eb105b483f7746b8805b9b989342085795b7" + integrity sha512-DAPhYzTYrRcXdMjUtUjKvW9z/FNAMTdU0ORyMcbmkwYNbKocDpdk+PX1L1dQgOID/+vVs1uBQ7ElrBQfZ0cuiQ== + dependencies: + "@types/mdast" "^3.0.0" + mdast-util-to-markdown "^1.3.0" + +mdast-util-gfm-table@^1.0.0: + version "1.0.7" + resolved "https://registry.yarnpkg.com/mdast-util-gfm-table/-/mdast-util-gfm-table-1.0.7.tgz#3552153a146379f0f9c4c1101b071d70bbed1a46" + integrity sha512-jjcpmNnQvrmN5Vx7y7lEc2iIOEytYv7rTvu+MeyAsSHTASGCCRA79Igg2uKssgOs1i1po8s3plW0sTu1wkkLGg== + dependencies: + "@types/mdast" "^3.0.0" + markdown-table "^3.0.0" + mdast-util-from-markdown "^1.0.0" + mdast-util-to-markdown "^1.3.0" + +mdast-util-gfm-task-list-item@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-1.0.2.tgz#b280fcf3b7be6fd0cc012bbe67a59831eb34097b" + integrity sha512-PFTA1gzfp1B1UaiJVyhJZA1rm0+Tzn690frc/L8vNX1Jop4STZgOE6bxUhnzdVSB+vm2GU1tIsuQcA9bxTQpMQ== + dependencies: + "@types/mdast" "^3.0.0" + mdast-util-to-markdown "^1.3.0" + +mdast-util-gfm@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/mdast-util-gfm/-/mdast-util-gfm-2.0.2.tgz#e92f4d8717d74bdba6de57ed21cc8b9552e2d0b6" + integrity sha512-qvZ608nBppZ4icQlhQQIAdc6S3Ffj9RGmzwUKUWuEICFnd1LVkN3EktF7ZHAgfcEdvZB5owU9tQgt99e2TlLjg== + dependencies: + mdast-util-from-markdown "^1.0.0" + mdast-util-gfm-autolink-literal "^1.0.0" + mdast-util-gfm-footnote "^1.0.0" + mdast-util-gfm-strikethrough "^1.0.0" + mdast-util-gfm-table "^1.0.0" + mdast-util-gfm-task-list-item "^1.0.0" + mdast-util-to-markdown "^1.0.0" + +mdast-util-phrasing@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/mdast-util-phrasing/-/mdast-util-phrasing-3.0.1.tgz#c7c21d0d435d7fb90956038f02e8702781f95463" + integrity sha512-WmI1gTXUBJo4/ZmSk79Wcb2HcjPJBzM1nlI/OUWA8yk2X9ik3ffNbBGsU+09BFmXaL1IBb9fiuvq6/KMiNycSg== + dependencies: + "@types/mdast" "^3.0.0" + unist-util-is "^5.0.0" + +mdast-util-to-markdown@^1.0.0, mdast-util-to-markdown@^1.3.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/mdast-util-to-markdown/-/mdast-util-to-markdown-1.5.0.tgz#c13343cb3fc98621911d33b5cd42e7d0731171c6" + integrity sha512-bbv7TPv/WC49thZPg3jXuqzuvI45IL2EVAr/KxF0BSdHsU0ceFHOmwQn6evxAh1GaoK/6GQ1wp4R4oW2+LFL/A== + dependencies: + "@types/mdast" "^3.0.0" + "@types/unist" "^2.0.0" + longest-streak "^3.0.0" + mdast-util-phrasing "^3.0.0" + mdast-util-to-string "^3.0.0" + micromark-util-decode-string "^1.0.0" + unist-util-visit "^4.0.0" + zwitch "^2.0.0" + mdast-util-to-string@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/mdast-util-to-string/-/mdast-util-to-string-1.1.0.tgz#27055500103f51637bd07d01da01eb1967a43527" integrity sha512-jVU0Nr2B9X3MU4tSK7JP1CMkSvOj7X5l/GboG1tKRw52lLF1x2Ju92Ms9tNetCcbfX3hzlM73zYo2NKkWSfF/A== +mdast-util-to-string@^3.0.0, mdast-util-to-string@^3.1.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/mdast-util-to-string/-/mdast-util-to-string-3.2.0.tgz#66f7bb6324756741c5f47a53557f0cbf16b6f789" + integrity sha512-V4Zn/ncyN1QNSqSBxTrMOLpjr+IKdHl2v3KVLoWmDPscP4r9GcCi71gjgvUV1SFSKh92AjAG4peFuBl2/YgCJg== + dependencies: + "@types/mdast" "^3.0.0" + mdurl@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-1.0.1.tgz#fe85b2ec75a59037f2adfec100fd6c601761152e" @@ -10827,6 +11040,279 @@ methods@~1.1.2: resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== +micromark-core-commonmark@^1.0.0, micromark-core-commonmark@^1.0.1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/micromark-core-commonmark/-/micromark-core-commonmark-1.1.0.tgz#1386628df59946b2d39fb2edfd10f3e8e0a75bb8" + integrity sha512-BgHO1aRbolh2hcrzL2d1La37V0Aoz73ymF8rAcKnohLy93titmv62E0gP8Hrx9PKcKrqCZ1BbLGbP3bEhoXYlw== + dependencies: + decode-named-character-reference "^1.0.0" + micromark-factory-destination "^1.0.0" + micromark-factory-label "^1.0.0" + micromark-factory-space "^1.0.0" + micromark-factory-title "^1.0.0" + micromark-factory-whitespace "^1.0.0" + micromark-util-character "^1.0.0" + micromark-util-chunked "^1.0.0" + micromark-util-classify-character "^1.0.0" + micromark-util-html-tag-name "^1.0.0" + micromark-util-normalize-identifier "^1.0.0" + micromark-util-resolve-all "^1.0.0" + micromark-util-subtokenize "^1.0.0" + micromark-util-symbol "^1.0.0" + micromark-util-types "^1.0.1" + uvu "^0.5.0" + +micromark-extension-gfm-autolink-literal@^1.0.0: + version "1.0.5" + resolved "https://registry.yarnpkg.com/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-1.0.5.tgz#5853f0e579bbd8ef9e39a7c0f0f27c5a063a66e7" + integrity sha512-z3wJSLrDf8kRDOh2qBtoTRD53vJ+CWIyo7uyZuxf/JAbNJjiHsOpG1y5wxk8drtv3ETAHutCu6N3thkOOgueWg== + dependencies: + micromark-util-character "^1.0.0" + micromark-util-sanitize-uri "^1.0.0" + micromark-util-symbol "^1.0.0" + micromark-util-types "^1.0.0" + +micromark-extension-gfm-footnote@^1.0.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-1.1.2.tgz#05e13034d68f95ca53c99679040bc88a6f92fe2e" + integrity sha512-Yxn7z7SxgyGWRNa4wzf8AhYYWNrwl5q1Z8ii+CSTTIqVkmGZF1CElX2JI8g5yGoM3GAman9/PVCUFUSJ0kB/8Q== + dependencies: + micromark-core-commonmark "^1.0.0" + micromark-factory-space "^1.0.0" + micromark-util-character "^1.0.0" + micromark-util-normalize-identifier "^1.0.0" + micromark-util-sanitize-uri "^1.0.0" + micromark-util-symbol "^1.0.0" + micromark-util-types "^1.0.0" + uvu "^0.5.0" + +micromark-extension-gfm-strikethrough@^1.0.0: + version "1.0.7" + resolved "https://registry.yarnpkg.com/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-1.0.7.tgz#c8212c9a616fa3bf47cb5c711da77f4fdc2f80af" + integrity sha512-sX0FawVE1o3abGk3vRjOH50L5TTLr3b5XMqnP9YDRb34M0v5OoZhG+OHFz1OffZ9dlwgpTBKaT4XW/AsUVnSDw== + dependencies: + micromark-util-chunked "^1.0.0" + micromark-util-classify-character "^1.0.0" + micromark-util-resolve-all "^1.0.0" + micromark-util-symbol "^1.0.0" + micromark-util-types "^1.0.0" + uvu "^0.5.0" + +micromark-extension-gfm-table@^1.0.0: + version "1.0.7" + resolved "https://registry.yarnpkg.com/micromark-extension-gfm-table/-/micromark-extension-gfm-table-1.0.7.tgz#dcb46074b0c6254c3fc9cc1f6f5002c162968008" + integrity sha512-3ZORTHtcSnMQEKtAOsBQ9/oHp9096pI/UvdPtN7ehKvrmZZ2+bbWhi0ln+I9drmwXMt5boocn6OlwQzNXeVeqw== + dependencies: + micromark-factory-space "^1.0.0" + micromark-util-character "^1.0.0" + micromark-util-symbol "^1.0.0" + micromark-util-types "^1.0.0" + uvu "^0.5.0" + +micromark-extension-gfm-tagfilter@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-1.0.2.tgz#aa7c4dd92dabbcb80f313ebaaa8eb3dac05f13a7" + integrity sha512-5XWB9GbAUSHTn8VPU8/1DBXMuKYT5uOgEjJb8gN3mW0PNW5OPHpSdojoqf+iq1xo7vWzw/P8bAHY0n6ijpXF7g== + dependencies: + micromark-util-types "^1.0.0" + +micromark-extension-gfm-task-list-item@^1.0.0: + version "1.0.5" + resolved "https://registry.yarnpkg.com/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-1.0.5.tgz#b52ce498dc4c69b6a9975abafc18f275b9dde9f4" + integrity sha512-RMFXl2uQ0pNQy6Lun2YBYT9g9INXtWJULgbt01D/x8/6yJ2qpKyzdZD3pi6UIkzF++Da49xAelVKUeUMqd5eIQ== + dependencies: + micromark-factory-space "^1.0.0" + micromark-util-character "^1.0.0" + micromark-util-symbol "^1.0.0" + micromark-util-types "^1.0.0" + uvu "^0.5.0" + +micromark-extension-gfm@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/micromark-extension-gfm/-/micromark-extension-gfm-2.0.3.tgz#e517e8579949a5024a493e49204e884aa74f5acf" + integrity sha512-vb9OoHqrhCmbRidQv/2+Bc6pkP0FrtlhurxZofvOEy5o8RtuuvTq+RQ1Vw5ZDNrVraQZu3HixESqbG+0iKk/MQ== + dependencies: + micromark-extension-gfm-autolink-literal "^1.0.0" + micromark-extension-gfm-footnote "^1.0.0" + micromark-extension-gfm-strikethrough "^1.0.0" + micromark-extension-gfm-table "^1.0.0" + micromark-extension-gfm-tagfilter "^1.0.0" + micromark-extension-gfm-task-list-item "^1.0.0" + micromark-util-combine-extensions "^1.0.0" + micromark-util-types "^1.0.0" + +micromark-factory-destination@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/micromark-factory-destination/-/micromark-factory-destination-1.1.0.tgz#eb815957d83e6d44479b3df640f010edad667b9f" + integrity sha512-XaNDROBgx9SgSChd69pjiGKbV+nfHGDPVYFs5dOoDd7ZnMAE+Cuu91BCpsY8RT2NP9vo/B8pds2VQNCLiu0zhg== + dependencies: + micromark-util-character "^1.0.0" + micromark-util-symbol "^1.0.0" + micromark-util-types "^1.0.0" + +micromark-factory-label@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/micromark-factory-label/-/micromark-factory-label-1.1.0.tgz#cc95d5478269085cfa2a7282b3de26eb2e2dec68" + integrity sha512-OLtyez4vZo/1NjxGhcpDSbHQ+m0IIGnT8BoPamh+7jVlzLJBH98zzuCoUeMxvM6WsNeh8wx8cKvqLiPHEACn0w== + dependencies: + micromark-util-character "^1.0.0" + micromark-util-symbol "^1.0.0" + micromark-util-types "^1.0.0" + uvu "^0.5.0" + +micromark-factory-space@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/micromark-factory-space/-/micromark-factory-space-1.1.0.tgz#c8f40b0640a0150751d3345ed885a080b0d15faf" + integrity sha512-cRzEj7c0OL4Mw2v6nwzttyOZe8XY/Z8G0rzmWQZTBi/jjwyw/U4uqKtUORXQrR5bAZZnbTI/feRV/R7hc4jQYQ== + dependencies: + micromark-util-character "^1.0.0" + micromark-util-types "^1.0.0" + +micromark-factory-title@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/micromark-factory-title/-/micromark-factory-title-1.1.0.tgz#dd0fe951d7a0ac71bdc5ee13e5d1465ad7f50ea1" + integrity sha512-J7n9R3vMmgjDOCY8NPw55jiyaQnH5kBdV2/UXCtZIpnHH3P6nHUKaH7XXEYuWwx/xUJcawa8plLBEjMPU24HzQ== + dependencies: + micromark-factory-space "^1.0.0" + micromark-util-character "^1.0.0" + micromark-util-symbol "^1.0.0" + micromark-util-types "^1.0.0" + +micromark-factory-whitespace@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/micromark-factory-whitespace/-/micromark-factory-whitespace-1.1.0.tgz#798fb7489f4c8abafa7ca77eed6b5745853c9705" + integrity sha512-v2WlmiymVSp5oMg+1Q0N1Lxmt6pMhIHD457whWM7/GUlEks1hI9xj5w3zbc4uuMKXGisksZk8DzP2UyGbGqNsQ== + dependencies: + micromark-factory-space "^1.0.0" + micromark-util-character "^1.0.0" + micromark-util-symbol "^1.0.0" + micromark-util-types "^1.0.0" + +micromark-util-character@^1.0.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/micromark-util-character/-/micromark-util-character-1.2.0.tgz#4fedaa3646db249bc58caeb000eb3549a8ca5dcc" + integrity sha512-lXraTwcX3yH/vMDaFWCQJP1uIszLVebzUa3ZHdrgxr7KEU/9mL4mVgCpGbyhvNLNlauROiNUq7WN5u7ndbY6xg== + dependencies: + micromark-util-symbol "^1.0.0" + micromark-util-types "^1.0.0" + +micromark-util-chunked@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/micromark-util-chunked/-/micromark-util-chunked-1.1.0.tgz#37a24d33333c8c69a74ba12a14651fd9ea8a368b" + integrity sha512-Ye01HXpkZPNcV6FiyoW2fGZDUw4Yc7vT0E9Sad83+bEDiCJ1uXu0S3mr8WLpsz3HaG3x2q0HM6CTuPdcZcluFQ== + dependencies: + micromark-util-symbol "^1.0.0" + +micromark-util-classify-character@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/micromark-util-classify-character/-/micromark-util-classify-character-1.1.0.tgz#6a7f8c8838e8a120c8e3c4f2ae97a2bff9190e9d" + integrity sha512-SL0wLxtKSnklKSUplok1WQFoGhUdWYKggKUiqhX+Swala+BtptGCu5iPRc+xvzJ4PXE/hwM3FNXsfEVgoZsWbw== + dependencies: + micromark-util-character "^1.0.0" + micromark-util-symbol "^1.0.0" + micromark-util-types "^1.0.0" + +micromark-util-combine-extensions@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/micromark-util-combine-extensions/-/micromark-util-combine-extensions-1.1.0.tgz#192e2b3d6567660a85f735e54d8ea6e3952dbe84" + integrity sha512-Q20sp4mfNf9yEqDL50WwuWZHUrCO4fEyeDCnMGmG5Pr0Cz15Uo7KBs6jq+dq0EgX4DPwwrh9m0X+zPV1ypFvUA== + dependencies: + micromark-util-chunked "^1.0.0" + micromark-util-types "^1.0.0" + +micromark-util-decode-numeric-character-reference@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-1.1.0.tgz#b1e6e17009b1f20bc652a521309c5f22c85eb1c6" + integrity sha512-m9V0ExGv0jB1OT21mrWcuf4QhP46pH1KkfWy9ZEezqHKAxkj4mPCy3nIH1rkbdMlChLHX531eOrymlwyZIf2iw== + dependencies: + micromark-util-symbol "^1.0.0" + +micromark-util-decode-string@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/micromark-util-decode-string/-/micromark-util-decode-string-1.1.0.tgz#dc12b078cba7a3ff690d0203f95b5d5537f2809c" + integrity sha512-YphLGCK8gM1tG1bd54azwyrQRjCFcmgj2S2GoJDNnh4vYtnL38JS8M4gpxzOPNyHdNEpheyWXCTnnTDY3N+NVQ== + dependencies: + decode-named-character-reference "^1.0.0" + micromark-util-character "^1.0.0" + micromark-util-decode-numeric-character-reference "^1.0.0" + micromark-util-symbol "^1.0.0" + +micromark-util-encode@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/micromark-util-encode/-/micromark-util-encode-1.1.0.tgz#92e4f565fd4ccb19e0dcae1afab9a173bbeb19a5" + integrity sha512-EuEzTWSTAj9PA5GOAs992GzNh2dGQO52UvAbtSOMvXTxv3Criqb6IOzJUBCmEqrrXSblJIJBbFFv6zPxpreiJw== + +micromark-util-html-tag-name@^1.0.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/micromark-util-html-tag-name/-/micromark-util-html-tag-name-1.2.0.tgz#48fd7a25826f29d2f71479d3b4e83e94829b3588" + integrity sha512-VTQzcuQgFUD7yYztuQFKXT49KghjtETQ+Wv/zUjGSGBioZnkA4P1XXZPT1FHeJA6RwRXSF47yvJ1tsJdoxwO+Q== + +micromark-util-normalize-identifier@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-1.1.0.tgz#7a73f824eb9f10d442b4d7f120fecb9b38ebf8b7" + integrity sha512-N+w5vhqrBihhjdpM8+5Xsxy71QWqGn7HYNUvch71iV2PM7+E3uWGox1Qp90loa1ephtCxG2ftRV/Conitc6P2Q== + dependencies: + micromark-util-symbol "^1.0.0" + +micromark-util-resolve-all@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/micromark-util-resolve-all/-/micromark-util-resolve-all-1.1.0.tgz#4652a591ee8c8fa06714c9b54cd6c8e693671188" + integrity sha512-b/G6BTMSg+bX+xVCshPTPyAu2tmA0E4X98NSR7eIbeC6ycCqCeE7wjfDIgzEbkzdEVJXRtOG4FbEm/uGbCRouA== + dependencies: + micromark-util-types "^1.0.0" + +micromark-util-sanitize-uri@^1.0.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-1.2.0.tgz#613f738e4400c6eedbc53590c67b197e30d7f90d" + integrity sha512-QO4GXv0XZfWey4pYFndLUKEAktKkG5kZTdUNaTAkzbuJxn2tNBOr+QtxR2XpWaMhbImT2dPzyLrPXLlPhph34A== + dependencies: + micromark-util-character "^1.0.0" + micromark-util-encode "^1.0.0" + micromark-util-symbol "^1.0.0" + +micromark-util-subtokenize@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/micromark-util-subtokenize/-/micromark-util-subtokenize-1.1.0.tgz#941c74f93a93eaf687b9054aeb94642b0e92edb1" + integrity sha512-kUQHyzRoxvZO2PuLzMt2P/dwVsTiivCK8icYTeR+3WgbuPqfHgPPy7nFKbeqRivBvn/3N3GBiNC+JRTMSxEC7A== + dependencies: + micromark-util-chunked "^1.0.0" + micromark-util-symbol "^1.0.0" + micromark-util-types "^1.0.0" + uvu "^0.5.0" + +micromark-util-symbol@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/micromark-util-symbol/-/micromark-util-symbol-1.1.0.tgz#813cd17837bdb912d069a12ebe3a44b6f7063142" + integrity sha512-uEjpEYY6KMs1g7QfJ2eX1SQEV+ZT4rUD3UcF6l57acZvLNK7PBZL+ty82Z1qhK1/yXIY4bdx04FKMgR0g4IAag== + +micromark-util-types@^1.0.0, micromark-util-types@^1.0.1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/micromark-util-types/-/micromark-util-types-1.1.0.tgz#e6676a8cae0bb86a2171c498167971886cb7e283" + integrity sha512-ukRBgie8TIAcacscVHSiddHjO4k/q3pnedmzMQ4iwDcK0FtFCohKOlFbaOL/mPgfnPsL3C1ZyxJa4sbWrBl3jg== + +micromark@^3.0.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/micromark/-/micromark-3.2.0.tgz#1af9fef3f995ea1ea4ac9c7e2f19c48fd5c006e9" + integrity sha512-uD66tJj54JLYq0De10AhWycZWGQNUvDI55xPgk2sQM5kn1JYlhbCMTtEeT27+vAhW2FBQxLlOmS3pmA7/2z4aA== + dependencies: + "@types/debug" "^4.0.0" + debug "^4.0.0" + decode-named-character-reference "^1.0.0" + micromark-core-commonmark "^1.0.1" + micromark-factory-space "^1.0.0" + micromark-util-character "^1.0.0" + micromark-util-chunked "^1.0.0" + micromark-util-combine-extensions "^1.0.0" + micromark-util-decode-numeric-character-reference "^1.0.0" + micromark-util-encode "^1.0.0" + micromark-util-normalize-identifier "^1.0.0" + micromark-util-resolve-all "^1.0.0" + micromark-util-sanitize-uri "^1.0.0" + micromark-util-subtokenize "^1.0.0" + micromark-util-symbol "^1.0.0" + micromark-util-types "^1.0.1" + uvu "^0.5.0" + micromatch@^4.0.2, micromatch@^4.0.4, micromatch@^4.0.5: version "4.0.5" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6" @@ -10918,7 +11404,7 @@ minimatch@^9.0.1: dependencies: brace-expansion "^2.0.1" -minimist@^1.2.0, minimist@^1.2.3, minimist@^1.2.5, minimist@^1.2.6, minimist@^1.2.8: +minimist@^1.2.0, minimist@^1.2.5, minimist@^1.2.6, minimist@^1.2.8: version "1.2.8" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== @@ -11006,7 +11492,7 @@ moo@^0.5.0: resolved "https://registry.yarnpkg.com/moo/-/moo-0.5.2.tgz#f9fe82473bc7c184b0d32e2215d3f6e67278733c" integrity sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q== -mri@^1.2.0: +mri@^1.1.0, mri@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/mri/-/mri-1.2.0.tgz#6721480fec2a11a4889861115a48b6cbe7cc8f0b" integrity sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA== @@ -11134,7 +11620,15 @@ node-fetch-native@^1.0.2: resolved "https://registry.yarnpkg.com/node-fetch-native/-/node-fetch-native-1.0.2.tgz#de3651399fda89a1a7c0bf6e7c4e9c239e8d0697" integrity sha512-KIkvH1jl6b3O7es/0ShyCgWLcfXxlBrLBbP3rOr23WArC66IMcU4DeZEeYEOwnopYhawLTn7/y+YtmASe8DFVQ== -node-fetch@^1.0.1, node-fetch@^2.0.0, node-fetch@^2.6.7: +node-fetch@^1.0.1: + version "1.7.3" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-1.7.3.tgz#980f6f72d85211a5347c6b2bc18c5b84c3eb47ef" + integrity sha512-NhZ4CsKx7cYm2vSrBAr2PvFOe6sWDf0UYLRqA6svUYg7+/TSfVAu49jYC4BvQ4Sms9SZgdqGBgroqfDhJdTyKQ== + dependencies: + encoding "^0.1.11" + is-stream "^1.0.1" + +node-fetch@^2.0.0, node-fetch@^2.6.7: version "2.6.9" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.9.tgz#7c7f744b5cc6eb5fd404e0c7a9fec630a55657e6" integrity sha512-DJm/CJkZkRjKKj4Zi4BsKVZh3ValV5IR5s7LVZnW+6YMh0W1BfNA8XSs6DLMGYlId5F3KnA70uu2qepcR08Qqg== @@ -12671,6 +13165,16 @@ remark-external-links@^8.0.0: space-separated-tokens "^1.0.0" unist-util-visit "^2.0.0" +remark-gfm@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/remark-gfm/-/remark-gfm-3.0.1.tgz#0b180f095e3036545e9dddac0e8df3fa5cfee54f" + integrity sha512-lEFDoi2PICJyNrACFOfDD3JlLkuSbOa5Wd8EPt06HUdptv8Gn0bxYTdbU/XXQ3swAPkEaGxxPN9cbnMHvVu1Ig== + dependencies: + "@types/mdast" "^3.0.0" + mdast-util-gfm "^2.0.0" + micromark-extension-gfm "^2.0.0" + unified "^10.0.0" + remark-slug@^6.0.0: version "6.1.0" resolved "https://registry.yarnpkg.com/remark-slug/-/remark-slug-6.1.0.tgz#0503268d5f0c4ecb1f33315c00465ccdd97923ce" @@ -12938,6 +13442,13 @@ rxjs@^7.5.5: dependencies: tslib "^2.1.0" +sade@^1.7.3: + version "1.8.1" + resolved "https://registry.yarnpkg.com/sade/-/sade-1.8.1.tgz#0a78e81d658d394887be57d2a409bf703a3b2701" + integrity sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A== + dependencies: + mri "^1.1.0" + safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: version "5.1.2" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" @@ -13020,7 +13531,17 @@ semver-compare@^1.0.0: resolved "https://registry.yarnpkg.com/semver-compare/-/semver-compare-1.0.0.tgz#0dee216a1c941ab37e9efb1788f6afc5ff5537fc" integrity sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow== -"semver@2 || 3 || 4 || 5", semver@^5.5.0, semver@^5.6.0, semver@^5.7.0, semver@^5.7.1, semver@^6.0.0, semver@^6.1.0, semver@^6.1.2, semver@^6.3.0, semver@^6.3.1, semver@^7.3.2, semver@^7.3.5, semver@^7.3.7, semver@^7.5.2, semver@^7.5.3: +"semver@2 || 3 || 4 || 5", semver@^5.5.0, semver@^5.6.0, semver@^5.7.0, semver@^5.7.1: + version "5.7.2" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8" + integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g== + +semver@^6.0.0, semver@^6.1.0, semver@^6.1.2, semver@^6.3.0, semver@^6.3.1: + version "6.3.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" + integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== + +semver@^7.3.2, semver@^7.3.5, semver@^7.3.7, semver@^7.5.3: version "7.5.4" resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e" integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA== @@ -13181,13 +13702,6 @@ simple-git@^3.19.0: "@kwsites/promise-deferred" "^1.1.1" debug "^4.3.4" -simple-update-notifier@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz#d70b92bdab7d6d90dfd73931195a30b6e3d7cebb" - integrity sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w== - dependencies: - semver "^7.5.3" - sirv@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/sirv/-/sirv-2.0.3.tgz#ca5868b87205a74bef62a469ed0296abceccd446" @@ -13394,12 +13908,12 @@ storybook-dark-mode@^3.0.3: fast-deep-equal "^3.1.3" memoizerific "^1.11.3" -storybook@~7.6.4: - version "7.6.4" - resolved "https://registry.yarnpkg.com/storybook/-/storybook-7.6.4.tgz#4f89d25be3990f0e057020efb0dcb3429dfa179c" - integrity sha512-nQhs9XkrroxjqMoBnnToyc6M8ndbmpkOb1qmULO4chtfMy4k0p9Un3K4TJvDaP8c3wPUFGd4ZaJ1hZNVmIl56Q== +storybook@^7.6.10: + version "7.6.10" + resolved "https://registry.yarnpkg.com/storybook/-/storybook-7.6.10.tgz#2185d26cd7b43390e3e2c7581586e2f60cdbd9bd" + integrity sha512-ypFeGhQTUBBfqSUVZYh7wS5ghn3O2wILCiQc4459SeUpvUn+skcqw/TlrwGSoF5EWjDA7gtRrWDxO3mnlPt5Cw== dependencies: - "@storybook/cli" "7.6.4" + "@storybook/cli" "7.6.10" stream-shift@^1.0.0: version "1.0.1" @@ -13955,6 +14469,11 @@ tree-kill@^1.2.1, tree-kill@^1.2.2: resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc" integrity sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A== +trough@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/trough/-/trough-2.1.0.tgz#0f7b511a4fde65a46f18477ab38849b22c554876" + integrity sha512-AqTiAOLcj85xS7vQ8QkAV41hPDIJ71XJB4RCUrzo/1GM2CQwhkJGaf9Hgr7BOugMRpgGUrqRg/DrBDl4H40+8g== + ts-dedent@^2.0.0, ts-dedent@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/ts-dedent/-/ts-dedent-2.2.0.tgz#39e4bd297cd036292ae2394eb3412be63f563bb5" @@ -14133,7 +14652,7 @@ typescript@^4.9.5: resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a" integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g== -ua-parser-js@^0.7.30, ua-parser-js@^0.7.33: +ua-parser-js@^0.7.30: version "0.7.37" resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.37.tgz#e464e66dac2d33a7a1251d7d7a99d6157ec27832" integrity sha512-xV8kqRKM+jhMvcHWUKthV9fNebIzrNy//2O9ZwWcfiBFR5f25XVZPLlEajk/sf3Ra15V92isyQqnIEXRDaZWEA== @@ -14191,6 +14710,19 @@ unicode-property-aliases-ecmascript@^2.0.0: resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz#43d41e3be698bd493ef911077c9b131f827e8ccd" integrity sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w== +unified@^10.0.0: + version "10.1.2" + resolved "https://registry.yarnpkg.com/unified/-/unified-10.1.2.tgz#b1d64e55dafe1f0b98bb6c719881103ecf6c86df" + integrity sha512-pUSWAi/RAnVy1Pif2kAoeWNBa3JVrx0MId2LASj8G+7AiHWoKZNTomq6LG326T68U7/e263X6fTdcXIy7XnF7Q== + dependencies: + "@types/unist" "^2.0.0" + bail "^2.0.0" + extend "^3.0.0" + is-buffer "^2.0.0" + is-plain-obj "^4.0.0" + trough "^2.0.0" + vfile "^5.0.0" + unique-string@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/unique-string/-/unique-string-2.0.0.tgz#39c6451f81afb2749de2b233e3f7c5e8843bd89d" @@ -14203,6 +14735,20 @@ unist-util-is@^4.0.0: resolved "https://registry.yarnpkg.com/unist-util-is/-/unist-util-is-4.1.0.tgz#976e5f462a7a5de73d94b706bac1b90671b57797" integrity sha512-ZOQSsnce92GrxSqlnEEseX0gi7GH9zTJZ0p9dtu87WRb/37mMPO2Ilx1s/t9vBHrFhbgweUwb+t7cIn5dxPhZg== +unist-util-is@^5.0.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/unist-util-is/-/unist-util-is-5.2.1.tgz#b74960e145c18dcb6226bc57933597f5486deae9" + integrity sha512-u9njyyfEh43npf1M+yGKDGVPbY/JWEemg5nH05ncKPfi+kBbKBJoTdsogMu33uhytuLlv9y0O7GH7fEdwLdLQw== + dependencies: + "@types/unist" "^2.0.0" + +unist-util-stringify-position@^3.0.0: + version "3.0.3" + resolved "https://registry.yarnpkg.com/unist-util-stringify-position/-/unist-util-stringify-position-3.0.3.tgz#03ad3348210c2d930772d64b489580c13a7db39d" + integrity sha512-k5GzIBZ/QatR8N5X2y+drfpWG8IDBzdnVj6OInRNWm1oXrzydiaAT2OQiA8DPRRZyAKb9b6I2a6PxYklZD0gKg== + dependencies: + "@types/unist" "^2.0.0" + unist-util-visit-parents@^3.0.0: version "3.1.1" resolved "https://registry.yarnpkg.com/unist-util-visit-parents/-/unist-util-visit-parents-3.1.1.tgz#65a6ce698f78a6b0f56aa0e88f13801886cdaef6" @@ -14211,6 +14757,14 @@ unist-util-visit-parents@^3.0.0: "@types/unist" "^2.0.0" unist-util-is "^4.0.0" +unist-util-visit-parents@^5.0.0, unist-util-visit-parents@^5.1.1: + version "5.1.3" + resolved "https://registry.yarnpkg.com/unist-util-visit-parents/-/unist-util-visit-parents-5.1.3.tgz#b4520811b0ca34285633785045df7a8d6776cfeb" + integrity sha512-x6+y8g7wWMyQhL1iZfhIPhDAs7Xwbn9nRosDXl7qoPTSCy0yNxnKc+hWokFifWQIDGi154rdUqKvbCa4+1kLhg== + dependencies: + "@types/unist" "^2.0.0" + unist-util-is "^5.0.0" + unist-util-visit@^2.0.0: version "2.0.3" resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-2.0.3.tgz#c3703893146df47203bb8a9795af47d7b971208c" @@ -14220,6 +14774,15 @@ unist-util-visit@^2.0.0: unist-util-is "^4.0.0" unist-util-visit-parents "^3.0.0" +unist-util-visit@^4.0.0: + version "4.1.2" + resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-4.1.2.tgz#125a42d1eb876283715a3cb5cceaa531828c72e2" + integrity sha512-MSd8OUGISqHdVvfY9TPhyK2VdUrPgxkUtWSuMHF6XAAFuL4LokseigBnZtPnJMu+FbynTkFNnFlyjxpVKujMRg== + dependencies: + "@types/unist" "^2.0.0" + unist-util-is "^5.0.0" + unist-util-visit-parents "^5.1.1" + universalify@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.2.0.tgz#6451760566fa857534745ab1dde952d1b1761be0" @@ -14362,6 +14925,16 @@ uuid@^9.0.0: resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.0.tgz#592f550650024a38ceb0c562f2f6aa435761efb5" integrity sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg== +uvu@^0.5.0: + version "0.5.6" + resolved "https://registry.yarnpkg.com/uvu/-/uvu-0.5.6.tgz#2754ca20bcb0bb59b64e9985e84d2e81058502df" + integrity sha512-+g8ENReyr8YsOc6fv/NVJs2vFdHBnBNdfE49rshrTzDWOlUx4Gq7KOS2GD8eqhy2j+Ejq29+SbKH8yjkAqXqoA== + dependencies: + dequal "^2.0.0" + diff "^5.0.0" + kleur "^4.0.3" + sade "^1.7.3" + v8-compile-cache-lib@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf" @@ -14408,6 +14981,24 @@ verror@1.10.0: core-util-is "1.0.2" extsprintf "^1.2.0" +vfile-message@^3.0.0: + version "3.1.4" + resolved "https://registry.yarnpkg.com/vfile-message/-/vfile-message-3.1.4.tgz#15a50816ae7d7c2d1fa87090a7f9f96612b59dea" + integrity sha512-fa0Z6P8HUrQN4BZaX05SIVXic+7kE3b05PWAtPuYP9QLHsLKYR7/AlLW3NtOrpXRLeawpDLMsVkmk5DG0NXgWw== + dependencies: + "@types/unist" "^2.0.0" + unist-util-stringify-position "^3.0.0" + +vfile@^5.0.0: + version "5.3.7" + resolved "https://registry.yarnpkg.com/vfile/-/vfile-5.3.7.tgz#de0677e6683e3380fafc46544cfe603118826ab7" + integrity sha512-r7qlzkgErKjobAmyNIkkSpizsFPYiUPuJb5pNW1RB4JcYVZhs4lIbVqk8XPk033CV/1z8ss5pkax8SuhGpcG8g== + dependencies: + "@types/unist" "^2.0.0" + is-buffer "^2.0.0" + unist-util-stringify-position "^3.0.0" + vfile-message "^3.0.0" + victory-vendor@^36.6.8: version "36.6.12" resolved "https://registry.yarnpkg.com/victory-vendor/-/victory-vendor-36.6.12.tgz#17fa4d79d266a6e2bde0291c60c5002c55008164" @@ -14703,7 +15294,7 @@ widest-line@^4.0.1: dependencies: string-width "^5.0.1" -word-wrap@^1.2.3, word-wrap@^1.2.4, word-wrap@~1.2.3: +word-wrap@^1.2.3, word-wrap@~1.2.3: version "1.2.5" resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== @@ -14852,19 +15443,29 @@ yallist@^4.0.0: resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== -yaml@^1.10.0, yaml@^1.7.2, yaml@^2.1.1, yaml@^2.2.2, yaml@^2.3.0: +yaml@^1.10.0, yaml@^1.7.2: + version "1.10.2" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b" + integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== + +yaml@^2.1.1, yaml@^2.2.2: version "2.3.4" resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.3.4.tgz#53fc1d514be80aabf386dc6001eb29bf3b7523b2" integrity sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA== -yargs-parser@^11.1.1, yargs-parser@^18.1.3, yargs-parser@^21.1.1: - version "18.1.3" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0" - integrity sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ== +yargs-parser@^11.1.1: + version "11.1.1" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-11.1.1.tgz#879a0865973bca9f6bab5cbdf3b1c67ec7d3bcf4" + integrity sha512-C6kB/WJDiaxONLJQnF8ccx9SEeoTTLek8RVbaOIsrAUS8VrBEXfmeSnCZxygc+XC2sNMBIwOOnfcxiynjHsVSQ== dependencies: camelcase "^5.0.0" decamelize "^1.2.0" +yargs-parser@^21.1.1: + version "21.1.1" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" + integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== + yargs@17.7.2, yargs@^17.3.1: version "17.7.2" resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269" @@ -14932,6 +15533,11 @@ yup@^0.32.9: property-expr "^2.0.4" toposort "^2.0.2" +zwitch@^2.0.0: + version "2.0.4" + resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-2.0.4.tgz#c827d4b0acb76fc3e685a4c6ec2902d51070e9d7" + integrity sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A== + zxcvbn@^4.4.2: version "4.4.2" resolved "https://registry.yarnpkg.com/zxcvbn/-/zxcvbn-4.4.2.tgz#28ec17cf09743edcab056ddd8b1b06262cc73c30" From e6c5a8ee4e03a0240151024f1dd2cdd33b01ffd6 Mon Sep 17 00:00:00 2001 From: Hussain Khalil <122488130+hkhalil-akamai@users.noreply.github.com> Date: Wed, 24 Jan 2024 16:54:54 -0500 Subject: [PATCH 14/44] upcoming: [M3-7580] - Add search field to Create Linode UIs (#10088) * Add Clone Linode power-off notice * Add new feature flag for Linode Clone UI updates * Added changeset: Clone Linode power-off notice * Reduce spacing between notices * Refactored DebouncedSearchTextField to enable external state management * Add search to SelectLinodePanel and autopopulate when cloning * Fix bug causing filter value to update every time a Linode is selected * Make clear icon blue on hover * Gate changes behind feature flag * Added changeset: Search filter in Clone Linode and Create Linode from Backup flows * Added -> upcoming * Add explanatory comment * Change InputAdornment to IconButton * Add missing useEffect dependencies * Fix unused customValue prop * Simplify export --- ...r-10088-upcoming-features-1705707697666.md | 5 + .../DebouncedSearchTextField.tsx | 164 ++++++++++-------- .../LinodesCreate/SelectLinodePanel.tsx | 62 ++++++- 3 files changed, 157 insertions(+), 74 deletions(-) create mode 100644 packages/manager/.changeset/pr-10088-upcoming-features-1705707697666.md diff --git a/packages/manager/.changeset/pr-10088-upcoming-features-1705707697666.md b/packages/manager/.changeset/pr-10088-upcoming-features-1705707697666.md new file mode 100644 index 00000000000..87ee6dddc78 --- /dev/null +++ b/packages/manager/.changeset/pr-10088-upcoming-features-1705707697666.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Search filter in Clone Linode and Create Linode from Backup flows ([#10088](https://github.com/linode/manager/pull/10088)) diff --git a/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextField.tsx b/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextField.tsx index fa8759bf210..9e4b7c320fb 100644 --- a/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextField.tsx +++ b/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextField.tsx @@ -1,3 +1,4 @@ +import Clear from '@mui/icons-material/Clear'; import Search from '@mui/icons-material/Search'; import { styled } from '@mui/material/styles'; import * as React from 'react'; @@ -5,10 +6,24 @@ import * as React from 'react'; import { CircleProgress } from 'src/components/CircleProgress'; import { InputAdornment } from 'src/components/InputAdornment'; import { TextField, TextFieldProps } from 'src/components/TextField'; -import { usePrevious } from 'src/hooks/usePrevious'; + +import { IconButton } from '../IconButton'; export interface DebouncedSearchProps extends TextFieldProps { className?: string; + /** + * Whether to show a clear button at the end of the input. + */ + clearable?: boolean; + /** + * Including this prop will disable this field from being self-managed. + * The user must then manage the state of the text field and provide a + * value and change handler. + */ + customValue?: { + onChange: (newValue: string | undefined) => void; + value: string | undefined; + }; /** * Interval in milliseconds of time that passes before search queries are accepted. * @default 400 @@ -16,6 +31,7 @@ export interface DebouncedSearchProps extends TextFieldProps { debounceTime?: number; defaultValue?: string; hideLabel?: boolean; + /** * Determines if the textbox is currently searching for inputted query */ @@ -23,82 +39,88 @@ export interface DebouncedSearchProps extends TextFieldProps { /** * Function to perform when searching for query */ - onSearch: (query: string) => void; + onSearch?: (query: string) => void; placeholder?: string; } -const DebouncedSearch = (props: DebouncedSearchProps) => { - const { - InputProps, - className, - debounceTime, - defaultValue, - hideLabel, - isSearching, - label, - onSearch, - placeholder, - ...restOfTextFieldProps - } = props; - const [query, setQuery] = React.useState(''); - const prevQuery = usePrevious(query); +export const DebouncedSearchTextField = React.memo( + (props: DebouncedSearchProps) => { + const { + InputProps, + className, + clearable, + customValue, + debounceTime, + defaultValue, + hideLabel, + isSearching, + label, + onSearch, + placeholder, + ...restOfTextFieldProps + } = props; - React.useEffect(() => { - /* - This `didCancel` business is to prevent a warning from React. - See: https://github.com/facebook/react/issues/14369#issuecomment-468267798 - */ - let didCancel = false; - /* - don't run the search if the query hasn't changed. - This is mostly to prevent this effect from running on first mount - */ - if ((prevQuery || '') !== query) { - setTimeout(() => { - if (!didCancel) { - onSearch(query); - } - }, debounceTime || 400); - } - return () => { - didCancel = true; - }; - }, [query]); - - const _setQuery = (e: React.ChangeEvent) => { - setQuery(e.target.value); - }; + // Manage the textfield state if customValue is not provided + const managedValue = React.useState(); + const [textFieldValue, setTextFieldValue] = customValue + ? [customValue.value, customValue.onChange] + : managedValue; - return ( - - - - ) : ( - - ), - startAdornment: ( - - - - ), - ...InputProps, - }} - className={className} - data-qa-debounced-search - defaultValue={defaultValue} - hideLabel={hideLabel} - label={label} - onChange={_setQuery} - placeholder={placeholder || 'Filter by query'} - {...restOfTextFieldProps} - /> - ); -}; + React.useEffect(() => { + if (textFieldValue != undefined) { + const timeout = setTimeout( + () => onSearch && onSearch(textFieldValue), + debounceTime !== undefined ? debounceTime : 400 + ); + return () => clearTimeout(timeout); + } + return undefined; + }, [debounceTime, onSearch, textFieldValue]); -export const DebouncedSearchTextField = React.memo(DebouncedSearch); + return ( + + + + ) : ( + clearable && ( + setTextFieldValue('')} + size="small" + > + ({ + '&&': { + color: theme.color.grey1, + }, + })} + /> + + ) + ), + startAdornment: ( + + + + ), + ...InputProps, + }} + className={className} + data-qa-debounced-search + defaultValue={defaultValue} + hideLabel={hideLabel} + label={label} + onChange={(e) => setTextFieldValue(e.target.value)} + placeholder={placeholder || 'Filter by query'} + value={textFieldValue} + {...restOfTextFieldProps} + /> + ); + } +); const StyledSearchIcon = styled(Search)(({ theme }) => ({ '&&, &&:hover': { diff --git a/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel.tsx b/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel.tsx index 278874650d4..f9cfbafd690 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel.tsx @@ -1,9 +1,10 @@ import { Linode } from '@linode/api-v4/lib/linodes'; import Grid from '@mui/material/Unstable_Grid2'; -import { styled } from '@mui/material/styles'; +import { styled, useTheme } from '@mui/material/styles'; import * as React from 'react'; import { Box } from 'src/components/Box'; +import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; import { Notice } from 'src/components/Notice/Notice'; import Paginate from 'src/components/Paginate'; import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; @@ -12,6 +13,7 @@ import { RenderGuard } from 'src/components/RenderGuard'; import { SelectionCard } from 'src/components/SelectionCard/SelectionCard'; import { Stack } from 'src/components/Stack'; import { Typography } from 'src/components/Typography'; +import { useFlags } from 'src/hooks/useFlags'; export interface ExtendedLinode extends Linode { heading: string; @@ -44,6 +46,36 @@ const SelectLinodePanel = (props: Props) => { selectedLinodeID, } = props; + const flags = useFlags(); + + const theme = useTheme(); + const [userSearchText, setUserSearchText] = React.useState< + string | undefined + >(undefined); + + // Capture the selected linode when this component mounts, + // so it doesn't change when the user selects a different one. + const [preselectedLinodeID] = React.useState( + flags.linodeCloneUIChanges && selectedLinodeID + ); + + const searchText = React.useMemo( + () => + userSearchText !== undefined + ? userSearchText + : linodes.find((linode) => linode.id === preselectedLinodeID)?.label || + '', + [linodes, preselectedLinodeID, userSearchText] + ); + + const filteredLinodes = React.useMemo( + () => + linodes.filter((linode) => + linode.label.toLowerCase().includes(searchText.toLowerCase()) + ), + [linodes, searchText] + ); + const renderCard = (linode: ExtendedLinode) => { return ( { }; return ( - + {({ count, data: linodesData, @@ -93,9 +125,33 @@ const SelectLinodePanel = (props: Props) => { /> ))} - + {!!header ? header : 'Select Linode'} + {flags.linodeCloneUIChanges && ( + + )} {linodesData.map((linode) => { From f7e327e847c5e19bd2d02f106cd3c84b2c3f96a6 Mon Sep 17 00:00:00 2001 From: cpathipa <119517080+cpathipa@users.noreply.github.com> Date: Thu, 25 Jan 2024 05:19:21 -0600 Subject: [PATCH 15/44] upcoming: [M3-7425] - OBJ MultiCluster - Add regions field in Create Access Key Flow (#10034) * Regions Multi Select * Add Regions field to AccessKeyDrawer * Added changeset: OBJ MultiCluster - Add regions field in Create Access Key Drawer * Get all buckets from regions useObjectStorageBucketsFromRegions * Show validation error when no regions are selected * Mock service for OBJ create key * Code cleanup * Create BucketPermissionsTable * Implement Copy All * Mock disable OBJ multicluster feature flag for access key tests * Create HostNamesDrawer * code cleanup * User session feedback * Update AccessKeyRegions.tsx * fix broken tests. * PR - Feedback from @jdamore-linode * PR - feedback use RemovableSelectionsList to render selected regions * Code cleanup * Code cleanup * Code cleanup * Remove omc_createObjectStorageKeysSchema as it is not required. * Fix - remove option * Code cleanup * PR feedback - @dwiley-akamai --------- Co-authored-by: Joe D'Amore --- packages/api-v4/src/object-storage/buckets.ts | 17 + ...r-10034-upcoming-features-1704431063670.md | 5 + .../core/objectStorage/access-key.e2e.spec.ts | 15 + .../objectStorage/access-keys.smoke.spec.ts | 15 + .../enable-object-storage.spec.ts | 5 + .../RegionMultiSelect.stories.tsx | 54 +-- .../RegionSelect/RegionMultiSelect.test.tsx | 4 +- .../RegionSelect/RegionMultiSelect.tsx | 4 +- .../RegionSelect/RegionSelect.types.ts | 4 +- .../AccessKeyLanding/AccessKeyLanding.tsx | 40 ++- .../AccessKeyLanding/AccessKeyMenu.tsx | 43 ++- .../AccessKeyRegions/AccessKeyRegions.tsx | 52 +++ .../AccessKeyRegions/SelectedRegionsList.tsx | 58 ++++ .../AccessKeyLanding/AccessKeyTable.tsx | 98 +++++- .../BucketPermissionsTable.tsx | 231 +++++++++++++ .../AccessKeyLanding/CopyAll.tsx | 50 +++ .../AccessKeyLanding/HostNamesDrawer.test.tsx | 80 +++++ .../AccessKeyLanding/HostNamesDrawer.tsx | 64 ++++ .../LimitedAccessControls.tsx | 20 +- .../AccessKeyLanding/OMC_AccessKeyDrawer.tsx | 313 ++++++++++++++++++ .../ObjectStorage/ObjectStorageLanding.tsx | 4 +- .../SecretTokenDialog/SecretTokenDialog.tsx | 52 ++- packages/manager/src/mocks/serverHandlers.ts | 58 +++- packages/manager/src/queries/objectStorage.ts | 57 ++++ .../manager/src/utilities/regions.test.ts | 60 ++++ packages/manager/src/utilities/regions.ts | 18 + 26 files changed, 1319 insertions(+), 102 deletions(-) create mode 100644 packages/manager/.changeset/pr-10034-upcoming-features-1704431063670.md create mode 100644 packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyRegions/AccessKeyRegions.tsx create mode 100644 packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyRegions/SelectedRegionsList.tsx create mode 100644 packages/manager/src/features/ObjectStorage/AccessKeyLanding/BucketPermissionsTable.tsx create mode 100644 packages/manager/src/features/ObjectStorage/AccessKeyLanding/CopyAll.tsx create mode 100644 packages/manager/src/features/ObjectStorage/AccessKeyLanding/HostNamesDrawer.test.tsx create mode 100644 packages/manager/src/features/ObjectStorage/AccessKeyLanding/HostNamesDrawer.tsx create mode 100644 packages/manager/src/features/ObjectStorage/AccessKeyLanding/OMC_AccessKeyDrawer.tsx create mode 100644 packages/manager/src/utilities/regions.test.ts create mode 100644 packages/manager/src/utilities/regions.ts diff --git a/packages/api-v4/src/object-storage/buckets.ts b/packages/api-v4/src/object-storage/buckets.ts index c206bfb7bba..52f09af3ce0 100644 --- a/packages/api-v4/src/object-storage/buckets.ts +++ b/packages/api-v4/src/object-storage/buckets.ts @@ -71,6 +71,23 @@ export const getBucketsInCluster = ( ) ); +/** + * getBucketsInRegion + * + * Gets a list of a user's Object Storage Buckets in the specified region. + */ +export const getBucketsInRegion = ( + regionId: string, + params?: Params, + filters?: Filter +) => + Request>( + setMethod('GET'), + setParams(params), + setXFilter(filters), + setURL(`${API_ROOT}/object-storage/buckets/${encodeURIComponent(regionId)}`) + ); + /** * createBucket * diff --git a/packages/manager/.changeset/pr-10034-upcoming-features-1704431063670.md b/packages/manager/.changeset/pr-10034-upcoming-features-1704431063670.md new file mode 100644 index 00000000000..8b0e14f0584 --- /dev/null +++ b/packages/manager/.changeset/pr-10034-upcoming-features-1704431063670.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +OBJ MultiCluster - Add regions field in Create Access Key Drawer ([#10034](https://github.com/linode/manager/pull/10034)) diff --git a/packages/manager/cypress/e2e/core/objectStorage/access-key.e2e.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/access-key.e2e.spec.ts index f4257397584..e0285bc6751 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/access-key.e2e.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/access-key.e2e.spec.ts @@ -5,10 +5,15 @@ import { objectStorageBucketFactory } from 'src/factories/objectStorage'; import { authenticate } from 'support/api/authentication'; import { createBucket } from '@linode/api-v4/lib/object-storage'; +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; import { interceptGetAccessKeys, interceptCreateAccessKey, } from 'support/intercepts/object-storage'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; import { randomLabel } from 'support/util/random'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; @@ -32,6 +37,11 @@ describe('object storage access key end-to-end tests', () => { interceptGetAccessKeys().as('getKeys'); interceptCreateAccessKey().as('createKey'); + mockAppendFeatureFlags({ + objMultiCluster: makeFeatureFlagData(false), + }); + mockGetFeatureFlagClientstream(); + cy.visitWithLogin('/object-storage/access-keys'); cy.wait('@getKeys'); @@ -119,6 +129,11 @@ describe('object storage access key end-to-end tests', () => { ).then(() => { const keyLabel = randomLabel(); + mockAppendFeatureFlags({ + objMultiCluster: makeFeatureFlagData(false), + }); + mockGetFeatureFlagClientstream(); + interceptGetAccessKeys().as('getKeys'); interceptCreateAccessKey().as('createKey'); diff --git a/packages/manager/cypress/e2e/core/objectStorage/access-keys.smoke.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/access-keys.smoke.spec.ts index 7b9f44abe2f..ac17fe65c95 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/access-keys.smoke.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/access-keys.smoke.spec.ts @@ -3,11 +3,16 @@ */ import { objectStorageKeyFactory } from 'src/factories/objectStorage'; +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; import { mockCreateAccessKey, mockDeleteAccessKey, mockGetAccessKeys, } from 'support/intercepts/object-storage'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; import { randomLabel, randomNumber, randomString } from 'support/util/random'; import { ui } from 'support/ui'; @@ -25,6 +30,11 @@ describe('object storage access keys smoke tests', () => { secret_key: randomString(39), }); + mockAppendFeatureFlags({ + objMultiCluster: makeFeatureFlagData(false), + }); + mockGetFeatureFlagClientstream(); + mockGetAccessKeys([]).as('getKeys'); mockCreateAccessKey(mockAccessKey).as('createKey'); @@ -90,6 +100,11 @@ describe('object storage access keys smoke tests', () => { secret_key: randomString(39), }); + mockAppendFeatureFlags({ + objMultiCluster: makeFeatureFlagData(false), + }); + mockGetFeatureFlagClientstream(); + // Mock initial GET request to include an access key. mockGetAccessKeys([accessKey]).as('getKeys'); mockDeleteAccessKey(accessKey.id).as('deleteKey'); diff --git a/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts index 8787747900f..0657aee5693 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts @@ -61,6 +61,11 @@ describe('Object Storage enrollment', () => { * - Confirms that consistent pricing information is shown for all regions in the enable modal. */ it('can enroll in Object Storage', () => { + mockAppendFeatureFlags({ + objMultiCluster: makeFeatureFlagData(false), + }); + mockGetFeatureFlagClientstream(); + const mockAccountSettings = accountSettingsFactory.build({ managed: false, object_storage: 'disabled', diff --git a/packages/manager/src/components/RegionSelect/RegionMultiSelect.stories.tsx b/packages/manager/src/components/RegionSelect/RegionMultiSelect.stories.tsx index 2fea491c3fe..ed4af598942 100644 --- a/packages/manager/src/components/RegionSelect/RegionMultiSelect.stories.tsx +++ b/packages/manager/src/components/RegionSelect/RegionMultiSelect.stories.tsx @@ -2,71 +2,19 @@ import React, { useState } from 'react'; import { regions } from 'src/__data__/regionsData'; import { Box } from 'src/components/Box'; -import { Flag } from 'src/components/Flag'; -import { - RemovableItem, - RemovableSelectionsList, -} from 'src/components/RemovableSelectionsList/RemovableSelectionsList'; +import { SelectedRegionsList } from 'src/features/ObjectStorage/AccessKeyLanding/AccessKeyRegions/SelectedRegionsList'; import { sortByString } from 'src/utilities/sort-by'; import { RegionMultiSelect } from './RegionMultiSelect'; -import { StyledFlagContainer } from './RegionSelect.styles'; import type { RegionMultiSelectProps } from './RegionSelect.types'; import type { Meta, StoryObj } from '@storybook/react'; import type { RegionSelectOption } from 'src/components/RegionSelect/RegionSelect.types'; -interface SelectedRegionsProps { - onRemove: (data: RegionSelectOption) => void; - selectedRegions: RegionSelectOption[]; -} - -interface LabelComponentProps { - selection: RemovableItem; -} - const sortRegionOptions = (a: RegionSelectOption, b: RegionSelectOption) => { return sortByString(a.label, b.label, 'asc'); }; -const LabelComponent = ({ selection }: LabelComponentProps) => { - return ( - - - - - {selection.label} - - ); -}; - -const SelectedRegionsList = ({ - onRemove, - selectedRegions, -}: SelectedRegionsProps) => { - const handleRemove = (item: RemovableItem) => { - onRemove(item.data); - }; - - return ( - { - return { ...item, id: index }; - })} - LabelComponent={LabelComponent} - headerText="" - noDataText="" - onRemove={handleRemove} - /> - ); -}; - export const Default: StoryObj = { render: (args) => { const SelectWrapper = () => { diff --git a/packages/manager/src/components/RegionSelect/RegionMultiSelect.test.tsx b/packages/manager/src/components/RegionSelect/RegionMultiSelect.test.tsx index 50357013bf2..9a055bb41ad 100644 --- a/packages/manager/src/components/RegionSelect/RegionMultiSelect.test.tsx +++ b/packages/manager/src/components/RegionSelect/RegionMultiSelect.test.tsx @@ -22,7 +22,7 @@ const regionsAtlanta = regionFactory.buildList(1, { label: 'Atlanta, GA', }); interface SelectedRegionsProps { - onRemove: (data: RegionSelectOption) => void; + onRemove: (region: string) => void; selectedRegions: RegionSelectOption[]; } const SelectedRegionsList = ({ @@ -33,7 +33,7 @@ const SelectedRegionsList = ({ {selectedRegions.map((region, index) => (
  • {region.label} - +
  • ))} diff --git a/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx b/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx index b47e926ac0d..b9acc2d0a1e 100644 --- a/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx +++ b/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx @@ -78,9 +78,9 @@ export const RegionMultiSelect = React.memo((props: RegionMultiSelectProps) => { [accountAvailability, currentCapability, regions] ); - const handleRemoveOption = (optionToRemove: RegionSelectOption) => { + const handleRemoveOption = (regionToRemove: string) => { const updatedSelectedOptions = selectedRegions.filter( - (option) => option.value !== optionToRemove.value + (option) => option.value !== regionToRemove ); const updatedSelectedIds = updatedSelectedOptions.map( (region) => region.value diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.types.ts b/packages/manager/src/components/RegionSelect/RegionSelect.types.ts index 708957002a8..90460892f2f 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.types.ts +++ b/packages/manager/src/components/RegionSelect/RegionSelect.types.ts @@ -1,3 +1,5 @@ +import React from 'react'; + import type { AccountAvailability, Capabilities, @@ -45,7 +47,7 @@ export interface RegionMultiSelectProps 'label' | 'onChange' | 'options' > { SelectedRegionsList?: React.ComponentType<{ - onRemove: (option: RegionSelectOption) => void; + onRemove: (region: string) => void; selectedRegions: RegionSelectOption[]; }>; currentCapability: Capabilities | undefined; diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.tsx index bb855062e82..b0c460837fe 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.tsx @@ -11,11 +11,14 @@ import * as React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; import { SecretTokenDialog } from 'src/features/Profile/SecretTokenDialog/SecretTokenDialog'; +import { useAccountManagement } from 'src/hooks/useAccountManagement'; import { useErrors } from 'src/hooks/useErrors'; +import { useFlags } from 'src/hooks/useFlags'; import { useOpenClose } from 'src/hooks/useOpenClose'; import { usePagination } from 'src/hooks/usePagination'; import { useAccountSettings } from 'src/queries/accountSettings'; import { useObjectStorageAccessKeys } from 'src/queries/objectStorage'; +import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; import { sendCreateAccessKeyEvent, sendEditAccessKeyEvent, @@ -25,6 +28,7 @@ import { getAPIErrorOrDefault, getErrorMap } from 'src/utilities/errorUtils'; import { AccessKeyDrawer } from './AccessKeyDrawer'; import { AccessKeyTable } from './AccessKeyTable'; +import { OMC_AccessKeyDrawer } from './OMC_AccessKeyDrawer'; import { RevokeAccessKeyDialog } from './RevokeAccessKeyDialog'; import ViewPermissionsDrawer from './ViewPermissionsDrawer'; import { MODE, OpenAccessDrawer } from './types'; @@ -81,6 +85,14 @@ export const AccessKeyLanding = (props: Props) => { const displayKeysDialog = useOpenClose(); const revokeKeysDialog = useOpenClose(); const viewPermissionsDrawer = useOpenClose(); + const flags = useFlags(); + const { account } = useAccountManagement(); + + const isObjMultiClusterEnabled = isFeatureEnabled( + 'Object Storage Access Key Regions', + Boolean(flags.objMultiCluster), + account?.capabilities ?? [] + ); const handleCreateKey = ( values: ObjectStorageKeyRequest, @@ -265,14 +277,26 @@ export const AccessKeyLanding = (props: Props) => { page={pagination.page} pageSize={pagination.pageSize} /> - + {isObjMultiClusterEnabled ? ( + + ) : ( + + )} +
    + {isObjMultiClusterEnabled && regionsLookup && ( + + {`${regionsLookup[eachKey?.regions[0]?.id].label}: ${ + eachKey?.regions[0]?.s3_endpoint + } `} + {eachKey?.regions?.length === 1 && ( + + )} + {eachKey.regions.length > 1 && ( + { + setHostNames(eachKey.regions); + setShowHostNamesDrawers(true); + }} + type="button" + > + and {eachKey.regions.length - 1} more... + + )} + + )} { }; return ( - - - - Label - Access Key - {/* empty cell for kebab menu */} - - - - {renderContent()} -
    + <> + + + + Label + Access Key + {isObjMultiClusterEnabled && ( + + Regions/S3 Hostnames + + )} + {/* empty cell for kebab menu */} + + + + {renderContent()} +
    + {isObjMultiClusterEnabled && ( + setShowHostNamesDrawers(false)} + open={showHostNamesDrawer} + regions={hostNames} + /> + )} + ); }; diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/BucketPermissionsTable.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/BucketPermissionsTable.tsx new file mode 100644 index 00000000000..bb3e41a7dc0 --- /dev/null +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/BucketPermissionsTable.tsx @@ -0,0 +1,231 @@ +import { AccessType, Scope } from '@linode/api-v4/lib/object-storage/types'; +import { update } from 'ramda'; +import * as React from 'react'; + +import { Radio } from 'src/components/Radio/Radio'; +import { TableBody } from 'src/components/TableBody'; +import { TableCell } from 'src/components/TableCell'; +import { TableHead } from 'src/components/TableHead'; +import { TableRow } from 'src/components/TableRow'; +import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; +import { useRegionsQuery } from 'src/queries/regions'; +import { getRegionsByRegionId } from 'src/utilities/regions'; + +import { AccessCell } from './AccessCell'; +import { + StyledBucketCell, + StyledClusterCell, + StyledRadioCell, + StyledRadioRow, + StyledTableRoot, +} from './AccessTable.styles'; + +import type { MODE } from './types'; + +export const getUpdatedScopes = ( + oldScopes: Scope[], + newScope: Scope +): Scope[] => { + // Region and bucket together form a primary key + const scopeToUpdate = oldScopes.findIndex( + (thisScope) => + thisScope.bucket_name === newScope.bucket_name && + thisScope.region === newScope.region + ); + if (scopeToUpdate < 0) { + return oldScopes; + } + return update(scopeToUpdate, newScope, oldScopes); +}; + +export const SCOPES: Record = { + none: 'none', + read: 'read_only', + write: 'read_write', +}; + +interface Props { + bucket_access: Scope[] | null; + checked: boolean; + mode: MODE; + selectedRegions?: string[]; + updateScopes: (newScopes: Scope[]) => void; +} + +export const BucketPermissionsTable = React.memo((props: Props) => { + const { bucket_access, checked, mode, selectedRegions, updateScopes } = props; + + const { data: regionsData } = useRegionsQuery(); + const regionsLookup = regionsData && getRegionsByRegionId(regionsData); + + if (!bucket_access || !regionsLookup) { + return null; + } + + const updateSingleScope = (newScope: Scope) => { + const newScopes = getUpdatedScopes(bucket_access, newScope); + updateScopes(newScopes); + }; + + const updateAllScopes = (accessType: AccessType) => { + const newScopes = bucket_access.map((thisScope) => ({ + ...thisScope, + permissions: accessType, + })); + updateScopes(newScopes); + }; + + const allScopesEqual = (accessType: AccessType) => { + return bucket_access.every( + (thisScope) => thisScope.permissions === accessType + ); + }; + + const disabled = !checked; + + return ( + + + + Region + Bucket + None + + Read Only + + + Read/Write + + + + + {mode === 'creating' && ( + + + Select All + + + updateAllScopes(SCOPES.none)} + value="none" + /> + + + updateAllScopes(SCOPES.read)} + value="read-only" + /> + + + updateAllScopes(SCOPES.write)} + value="read-write" + /> + + + )} + {bucket_access.length === 0 ? ( + + ) : ( + bucket_access.map((thisScope) => { + const scopeName = `${thisScope.region}-${thisScope.bucket_name}`; + return ( + + + {regionsLookup[thisScope.region ?? '']?.label} + + + {thisScope.bucket_name} + + + + updateSingleScope({ + ...thisScope, + permissions: SCOPES.none, + }) + } + active={thisScope.permissions === SCOPES.none} + disabled={disabled} + scope="none" + scopeDisplay={scopeName} + viewOnly={mode === 'viewing'} + /> + + + + updateSingleScope({ + ...thisScope, + permissions: SCOPES.read, + }) + } + active={thisScope.permissions === SCOPES.read} + disabled={disabled} + scope="read-only" + scopeDisplay={scopeName} + viewOnly={mode === 'viewing'} + /> + + + + updateSingleScope({ + ...thisScope, + permissions: SCOPES.write, + }) + } + active={thisScope.permissions === SCOPES.write} + disabled={disabled} + scope="read-write" + scopeDisplay={scopeName} + viewOnly={mode === 'viewing'} + /> + + + ); + }) + )} + + + ); +}); diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/CopyAll.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/CopyAll.tsx new file mode 100644 index 00000000000..521b73b7788 --- /dev/null +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/CopyAll.tsx @@ -0,0 +1,50 @@ +import { styled } from '@mui/material/styles'; +import { Box } from '@mui/system'; +import copy from 'copy-to-clipboard'; +import * as React from 'react'; + +import { StyledLinkButton } from 'src/components/Button/StyledLinkButton'; +import { InputLabel } from 'src/components/InputLabel'; +import { Tooltip } from 'src/components/Tooltip'; + +export interface Props { + text: string; +} + +export const CopyAll = (props: Props) => { + const [copied, setCopied] = React.useState(false); + const { text } = props; + + const handleIconClick = () => { + setCopied(true); + window.setTimeout(() => setCopied(false), 1500); + copy(text); + }; + + return ( + + S3 Endpoint Hostnames + + + Copy all + + + + ); +}; + +const StyledBox = styled(Box, { label: 'StyledBox' })(({ theme }) => ({ + borderColor: theme.name === 'light' ? '#ccc' : '#222', + display: 'flex', + justifyContent: 'space-between', +})); diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/HostNamesDrawer.test.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/HostNamesDrawer.test.tsx new file mode 100644 index 00000000000..f3e49b5c3d0 --- /dev/null +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/HostNamesDrawer.test.tsx @@ -0,0 +1,80 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { regionFactory } from 'src/factories/regions'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { HostNamesDrawer } from './HostNamesDrawer'; + +// Mock the onClose function +const mockOnClose = vi.fn(); + +// Mock regions data +const mockS3Regions = [ + { + id: 'region1', + s3_endpoint: 'endpoint1', + }, + { + id: 'region2', + s3_endpoint: 'endpoint2', + }, +]; + +vi.mock('src/queries/regions', () => ({ + useRegionsQuery: vi.fn(() => ({ + data: [ + ...regionFactory.buildList(1, { id: 'region1', label: 'Newark, NJ' }), + ...regionFactory.buildList(1, { id: 'region2', label: 'Atlanta, GA' }), + ], + })), +})); + +describe('HostNamesDrawer', () => { + it('renders the drawer with regions and copyable text', () => { + renderWithTheme( + + ); + + expect( + screen.getByRole('dialog', { name: 'Regions / S3 Hostnames' }) + ).toBeInTheDocument(); + + expect( + screen.getByRole('heading', { name: 'Regions / S3 Hostnames' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('textbox', { name: 'region1: endpoint1' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { + name: 'Copy S3 Endpoint: Atlanta, GA: endpoint2 to clipboard', + }) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { + name: 'Copy S3 Endpoint: Newark, NJ: endpoint1 to clipboard', + }) + ).toBeInTheDocument(); + }); + + it('calls onClose when the drawer is closed', () => { + renderWithTheme( + + ); + + const closeButton = screen.getByRole('button', { name: 'Close drawer' }); + userEvent.click(closeButton); + + expect(mockOnClose).toHaveBeenCalled(); + }); +}); diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/HostNamesDrawer.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/HostNamesDrawer.tsx new file mode 100644 index 00000000000..d5534b13e31 --- /dev/null +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/HostNamesDrawer.tsx @@ -0,0 +1,64 @@ +import { RegionS3EndpointAndID } from '@linode/api-v4'; +import * as React from 'react'; + +import { Box } from 'src/components/Box'; +import { CopyableTextField } from 'src/components/CopyableTextField/CopyableTextField'; +import { Drawer } from 'src/components/Drawer'; +import { useRegionsQuery } from 'src/queries/regions'; +import { getRegionsByRegionId } from 'src/utilities/regions'; + +import { CopyAll } from './CopyAll'; + +interface Props { + onClose: () => void; + open: boolean; + regions: RegionS3EndpointAndID[]; +} + +export const HostNamesDrawer = (props: Props) => { + const { onClose, open, regions } = props; + const { data: regionsData } = useRegionsQuery(); + const regionsLookup = regionsData && getRegionsByRegionId(regionsData); + + if (!regionsData || !regionsLookup) { + return null; + } + + return ( + + ({ marginTop: theme.spacing(3) })}> + + `S3 Endpoint: ${regionsLookup[region.id]?.label}: ${ + region.s3_endpoint + }` + ) + .join('\n') ?? '' + } + /> + + ({ + backgroundColor: theme.bg.main, + border: `1px solid ${theme.color.grey3}`, + padding: theme.spacing(1), + })} + > + {regions.map((region, index) => ( + + ))} + + + ); +}; diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/LimitedAccessControls.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/LimitedAccessControls.tsx index 5bfe7e99cf4..ccab6a2e5c4 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/LimitedAccessControls.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/LimitedAccessControls.tsx @@ -4,8 +4,12 @@ import * as React from 'react'; import { FormControlLabel } from 'src/components/FormControlLabel'; import { Toggle } from 'src/components/Toggle/Toggle'; import { Typography } from 'src/components/Typography'; +import { useAccountManagement } from 'src/hooks/useAccountManagement'; +import { useFlags } from 'src/hooks/useFlags'; +import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; import { AccessTable } from './AccessTable'; +import { BucketPermissionsTable } from './BucketPermissionsTable'; import { MODE } from './types'; interface Props { @@ -13,12 +17,22 @@ interface Props { checked: boolean; handleToggle: () => void; mode: MODE; + selectedRegions?: string[]; updateScopes: (newScopes: Scope[]) => void; } export const LimitedAccessControls = React.memo((props: Props) => { const { checked, handleToggle, ...rest } = props; + const flags = useFlags(); + const { account } = useAccountManagement(); + + const isObjMultiClusterEnabled = isFeatureEnabled( + 'Object Storage Access Key Regions', + Boolean(flags.objMultiCluster), + account?.capabilities ?? [] + ); + return ( <> { also create new buckets, but will not have access to the buckets they create. - + {isObjMultiClusterEnabled ? ( + + ) : ( + + )} ); }); diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/OMC_AccessKeyDrawer.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/OMC_AccessKeyDrawer.tsx new file mode 100644 index 00000000000..6d4979acab9 --- /dev/null +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/OMC_AccessKeyDrawer.tsx @@ -0,0 +1,313 @@ +import { Region } from '@linode/api-v4'; +import { + AccessType, + ObjectStorageBucket, + ObjectStorageKey, + ObjectStorageKeyRequest, + Scope, +} from '@linode/api-v4/lib/object-storage'; +import { createObjectStorageKeysSchema } from '@linode/validation/lib/objectStorageKeys.schema'; +import { useFormik } from 'formik'; +import React, { useEffect, useState } from 'react'; + +import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; +import { CircleProgress } from 'src/components/CircleProgress'; +import { Drawer } from 'src/components/Drawer'; +import { Link } from 'src/components/Link'; +import { Notice } from 'src/components/Notice/Notice'; +import { TextField } from 'src/components/TextField'; +import { Typography } from 'src/components/Typography'; +import { useAccountSettings } from 'src/queries/accountSettings'; +import { useObjectStorageBucketsFromRegions } from 'src/queries/objectStorage'; +import { useRegionsQuery } from 'src/queries/regions'; +import { getRegionsByRegionId } from 'src/utilities/regions'; +import { sortByString } from 'src/utilities/sort-by'; + +import { EnableObjectStorageModal } from '../EnableObjectStorageModal'; +import { confirmObjectStorage } from '../utilities'; +import { AccessKeyRegions } from './AccessKeyRegions/AccessKeyRegions'; +import { LimitedAccessControls } from './LimitedAccessControls'; +import { MODE } from './types'; + +export interface AccessKeyDrawerProps { + isRestrictedUser: boolean; + mode: MODE; + // If the mode is 'editing', we should have an ObjectStorageKey to edit + objectStorageKey?: ObjectStorageKey; + onClose: () => void; + onSubmit: (values: ObjectStorageKeyRequest, formikProps: any) => void; + open: boolean; +} + +export interface FormState { + bucket_access: Scope[] | null; + label: string; + regions: string[]; +} + +/** + * Helpers for converting a list of buckets + * on the user's account into a list of + * bucket_access in the shape the API will expect, + * sorted by region. + */ + +export const sortByRegion = (regionLookup: { [key: string]: Region }) => ( + a: Scope, + b: Scope +) => { + if (!a.region || !b.region) { + return 0; + } + + return sortByString( + regionLookup[a.region].label, + regionLookup[b.region].label, + 'asc' + ); +}; +export const getDefaultScopes = ( + buckets: ObjectStorageBucket[], + regionLookup: { [key: string]: Region } = {} +): Scope[] => + buckets + .map((thisBucket) => ({ + bucket_name: thisBucket.label, + cluster: thisBucket.cluster, + permissions: 'none' as AccessType, + region: thisBucket.region, + })) + .sort(sortByRegion(regionLookup)); + +export const OMC_AccessKeyDrawer = (props: AccessKeyDrawerProps) => { + const { + isRestrictedUser, + mode, + objectStorageKey, + onClose, + onSubmit, + open, + } = props; + + const { data: regions } = useRegionsQuery(); + + const regionsLookup = regions && getRegionsByRegionId(regions); + + const regionsSupportObjectStorage = regions?.filter((region) => + region.capabilities.includes('Object Storage') + ); + + const { + data: objectStorageBuckets, + error: bucketsError, + isLoading: areBucketsLoading, + } = useObjectStorageBucketsFromRegions(regionsSupportObjectStorage); + + const { data: accountSettings } = useAccountSettings(); + + const buckets = objectStorageBuckets?.buckets || []; + + const hasBuckets = buckets?.length > 0; + + const createMode = mode === 'creating'; + + const [dialogOpen, setDialogOpen] = useState(false); + // This is for local display management only, not part of the payload + // and so not included in Formik's types + const [limitedAccessChecked, setLimitedAccessChecked] = useState(false); + + const title = createMode ? 'Create Access Key' : 'Edit Access Key Label'; + + const initialLabelValue = + !createMode && objectStorageKey ? objectStorageKey.label : ''; + const initialRegions = + !createMode && objectStorageKey + ? objectStorageKey.regions?.map((region) => region.id) + : []; + + const initialValues: FormState = { + bucket_access: [], + label: initialLabelValue, + regions: initialRegions, + }; + + const formik = useFormik({ + initialValues, + onSubmit: (values) => { + // If the user hasn't toggled the Limited Access button, + // don't include any bucket_access information in the payload. + + // If any/all values are 'none', don't include them in the response. + const access = values.bucket_access ?? []; + const payload = limitedAccessChecked + ? { + ...values, + bucket_access: access.filter( + (thisAccess) => thisAccess.permissions !== 'none' + ), + } + : { ...values, bucket_access: null }; + + onSubmit(payload, formik); + }, + validateOnBlur: true, + validateOnChange: false, + validationSchema: createObjectStorageKeysSchema, + }); + + const beforeSubmit = () => { + confirmObjectStorage( + accountSettings?.object_storage || 'active', + formik, + () => setDialogOpen(true) + ); + }; + + const handleScopeUpdate = (newScopes: Scope[]) => { + formik.setFieldValue('bucket_access', newScopes); + }; + + const handleToggleAccess = () => { + setLimitedAccessChecked((checked) => !checked); + // Reset scopes + const bucketsInRegions = buckets?.filter( + (bucket) => bucket.region && formik.values.regions.includes(bucket.region) + ); + formik.setFieldValue( + 'bucket_access', + getDefaultScopes(bucketsInRegions, regionsLookup) + ); + }; + + useEffect(() => { + setLimitedAccessChecked(false); + formik.resetForm({ values: initialValues }); + }, [open]); + + return ( + + {areBucketsLoading ? ( + + ) : ( + <> + {formik.status && ( + + )} + + {isRestrictedUser && ( + + )} + + {/* Explainer copy if we're in 'creating' mode */} + {createMode && ( + + Generate an Access Key for use with an{' '} + + S3-compatible client + + . + + )} + + {!hasBuckets ? ( + + This key will have unlimited access to all buckets on your + account. The option to create a limited access key is only + available after creating one or more buckets. + + ) : null} + + + { + const bucketsInRegions = buckets?.filter( + (bucket) => + bucket.region && formik.values.regions.includes(bucket.region) + ); + + formik.setFieldValue( + 'bucket_access', + getDefaultScopes(bucketsInRegions, regionsLookup) + ); + }} + onChange={(values) => { + const bucketsInRegions = buckets?.filter( + (bucket) => bucket.region && values.includes(bucket.region) + ); + formik.setFieldValue( + 'bucket_access', + getDefaultScopes(bucketsInRegions, regionsLookup) + ); + formik.setFieldValue('regions', values); + }} + disabled={isRestrictedUser} + error={formik.errors.regions as string} + name="regions" + required + selectedRegion={formik.values.regions} + /> + {createMode && !bucketsError && ( + + )} + + setDialogOpen(false)} + open={dialogOpen} + /> + + )} + + ); +}; diff --git a/packages/manager/src/features/ObjectStorage/ObjectStorageLanding.tsx b/packages/manager/src/features/ObjectStorage/ObjectStorageLanding.tsx index 34a109ec00e..4b48d85ef4a 100644 --- a/packages/manager/src/features/ObjectStorage/ObjectStorageLanding.tsx +++ b/packages/manager/src/features/ObjectStorage/ObjectStorageLanding.tsx @@ -86,7 +86,7 @@ export const ObjectStorageLanding = () => { const flags = useFlags(); - const isObjMultiClusterFlagEnabled = isFeatureEnabled( + const isObjMultiClusterEnabled = isFeatureEnabled( 'Object Storage Access Key Regions', Boolean(flags.objMultiCluster), account?.capabilities ?? [] @@ -171,7 +171,7 @@ export const ObjectStorageLanding = () => { - {isObjMultiClusterFlagEnabled ? ( + {isObjMultiClusterEnabled ? ( history.replace('/object-storage/buckets')} diff --git a/packages/manager/src/features/Profile/SecretTokenDialog/SecretTokenDialog.tsx b/packages/manager/src/features/Profile/SecretTokenDialog/SecretTokenDialog.tsx index 192656f8375..9c6e01fe447 100644 --- a/packages/manager/src/features/Profile/SecretTokenDialog/SecretTokenDialog.tsx +++ b/packages/manager/src/features/Profile/SecretTokenDialog/SecretTokenDialog.tsx @@ -5,10 +5,14 @@ import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Box } from 'src/components/Box'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { CopyableAndDownloadableTextField } from 'src/components/CopyableAndDownloadableTextField'; +import { CopyableTextField } from 'src/components/CopyableTextField/CopyableTextField'; import { Notice } from 'src/components/Notice/Notice'; +import { CopyAll } from 'src/features/ObjectStorage/AccessKeyLanding/CopyAll'; +import { useAccountManagement } from 'src/hooks/useAccountManagement'; +import { useFlags } from 'src/hooks/useFlags'; +import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; import type { ObjectStorageKey } from '@linode/api-v4/lib/object-storage'; - interface Props { objectStorageKey?: ObjectStorageKey | null; onClose: () => void; @@ -33,6 +37,15 @@ const renderActions = ( export const SecretTokenDialog = (props: Props) => { const { objectStorageKey, onClose, open, title, value } = props; + const flags = useFlags(); + const { account } = useAccountManagement(); + + const isObjMultiClusterEnabled = isFeatureEnabled( + 'Object Storage Access Key Regions', + Boolean(flags.objMultiCluster), + account?.capabilities ?? [] + ); + const modalConfirmationButtonText = objectStorageKey ? 'I Have Saved My Secret Key' : `I Have Saved My ${title}`; @@ -58,6 +71,43 @@ export const SecretTokenDialog = (props: Props) => { spacingTop={8} variant="warning" /> + {isObjMultiClusterEnabled && ( +
    + `S3 Endpoint: ${region.id}: ${region.s3_endpoint}` + ) + .join('\n') ?? '' + } + /> +
    + )} + {isObjMultiClusterEnabled && ( + ({ + '.copyIcon': { + marginRight: 0, + paddingRight: 0, + }, + backgroundColor: theme.bg.main, + border: `1px solid ${theme.color.grey3}`, + borderColor: theme.name === 'light' ? '#ccc' : '#222', + padding: theme.spacing(1), + })} + > + {objectStorageKey?.regions.map((region, index) => ( + + ))} + + )} {objectStorageKey ? ( <> diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index c0007de8687..5d7ce0fd4fc 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -1,6 +1,7 @@ import { CreatePlacementGroupPayload, NotificationType, + ObjectStorageKeyRequest, SecurityQuestionsPayload, TokenRequest, User, @@ -938,15 +939,20 @@ export const handlers = [ ) ); }), - rest.get('*/object-storage/buckets/*', (req, res, ctx) => { + rest.get('*/object-storage/buckets/:region', (req, res, ctx) => { // Temporarily added pagination logic to make sure my use of // getAll worked for fetching all buckets. + const region = req.params.region as string; + objectStorageBucketFactory.resetSequenceNumber(); const page = Number(req.url.searchParams.get('page') || 1); const pageSize = Number(req.url.searchParams.get('page_size') || 25); - const buckets = objectStorageBucketFactory.buildList(1); + const buckets = objectStorageBucketFactory.buildList(1, { + cluster: `${region}-1`, + region, + }); return res( ctx.json({ @@ -994,10 +1000,56 @@ export const handlers = [ }), rest.get('*object-storage/keys', (req, res, ctx) => { return res( - ctx.json(makeResourcePage(objectStorageKeyFactory.buildList(3))) + ctx.json( + makeResourcePage([ + ...objectStorageKeyFactory.buildList(1), + ...objectStorageKeyFactory.buildList(1, { + regions: [ + { id: 'us-east', s3_endpoint: 'us-east.com' }, + { id: 'us-east', s3_endpoint: 'us-east.com' }, + { id: 'us-east', s3_endpoint: 'us-east.com' }, + { id: 'us-east', s3_endpoint: 'us-east.com' }, + { id: 'us-east', s3_endpoint: 'us-east.com' }, + { id: 'us-east', s3_endpoint: 'us-east.com' }, + { id: 'us-east', s3_endpoint: 'us-east.com' }, + ], + }), + ...objectStorageKeyFactory.buildList(1, { + regions: [ + { id: 'us-east', s3_endpoint: 'us-east.com' }, + { id: 'us-east', s3_endpoint: 'us-east.com' }, + { id: 'us-east', s3_endpoint: 'us-east.com' }, + { id: 'us-east', s3_endpoint: 'us-east.com' }, + { id: 'us-east', s3_endpoint: 'us-east.com' }, + ], + }), + ...objectStorageKeyFactory.buildList(1, { + regions: [ + { id: 'us-east', s3_endpoint: 'us-east.com' }, + { id: 'us-east', s3_endpoint: 'us-east.com' }, + ], + }), + ]) + ) ); }), + rest.post('*object-storage/keys', (req, res, ctx) => { + const { label, regions } = req.body as ObjectStorageKeyRequest; + + const regionsData = regions?.map((region: string) => ({ + id: region, + s3_endpoint: `${region}.com`, + })); + return res( + ctx.json( + objectStorageKeyFactory.build({ + label, + regions: regionsData, + }) + ) + ); + }), rest.get('*/domains', (req, res, ctx) => { const domains = domainFactory.buildList(10); return res(ctx.json(makeResourcePage(domains))); diff --git a/packages/manager/src/queries/objectStorage.ts b/packages/manager/src/queries/objectStorage.ts index 4c10f734e00..b5fe4bc750a 100644 --- a/packages/manager/src/queries/objectStorage.ts +++ b/packages/manager/src/queries/objectStorage.ts @@ -8,12 +8,14 @@ import { ObjectStorageObjectListResponse, ObjectStorageObjectURL, ObjectStorageObjectURLOptions, + Region, createBucket, deleteBucket, deleteSSLCert, getBucket, getBuckets, getBucketsInCluster, + getBucketsInRegion, getClusters, getObjectList, getObjectStorageKeys, @@ -37,8 +39,14 @@ import { queryKey as accountSettingsQueryKey } from './accountSettings'; import { queryPresets } from './base'; export interface BucketError { + /* + @TODO OBJ Multicluster: 'region' will become required, and the 'cluster' field will be deprecated + once the feature is fully rolled out in production as part of the process of cleaning up the 'objMultiCluster' + feature flag. + */ cluster: ObjectStorageCluster; error: APIError[]; + region?: Region; } interface BucketsResponce { @@ -84,6 +92,20 @@ export const useObjectStorageBuckets = ( } ); +export const useObjectStorageBucketsFromRegions = ( + regions: Region[] | undefined, + enabled: boolean = true +) => + useQuery( + `${queryKey}-buckets-from-regions`, + () => getAllBucketsFromRegions(regions), + { + ...queryPresets.longLived, + enabled: regions !== undefined && enabled, + retry: false, + } + ); + export const useObjectStorageAccessKeys = (params: Params) => useQuery, APIError[]>( [`${queryKey}-access-keys`, params], @@ -188,6 +210,41 @@ export const getAllBucketsFromClusters = async ( return { buckets, errors } as BucketsResponce; }; +export const getAllBucketsFromRegions = async ( + regions: Region[] | undefined +) => { + if (regions === undefined) { + return { buckets: [], errors: [] } as BucketsResponce; + } + + const promises = regions.map((region) => + getAll((params) => + getBucketsInRegion(region.id, params) + )() + .then((data) => data.data) + .catch((error) => ({ + error, + region, + })) + ); + + const data = await Promise.all(promises); + + const bucketsPerCluster = data.filter((item) => + Array.isArray(item) + ) as ObjectStorageBucket[][]; + + const buckets = bucketsPerCluster.reduce((acc, val) => acc.concat(val), []); + + const errors = data.filter((item) => !Array.isArray(item)) as BucketError[]; + + if (errors.length === regions.length) { + throw new Error('Unable to get Object Storage buckets.'); + } + + return { buckets, errors } as BucketsResponce; +}; + /** * Used to make a nice React Query queryKey by splitting the prefix * by the '/' character. diff --git a/packages/manager/src/utilities/regions.test.ts b/packages/manager/src/utilities/regions.test.ts new file mode 100644 index 00000000000..1a8b89931a6 --- /dev/null +++ b/packages/manager/src/utilities/regions.test.ts @@ -0,0 +1,60 @@ +import { Region } from '@linode/api-v4/lib/regions'; + +import { getRegionsByRegionId } from './regions'; + +describe('getRegionsByRegionId', () => { + it('converts an array of regions to a lookup object', () => { + const mockRegions: Region[] = [ + { + capabilities: ['Object Storage'], + country: 'us', + id: 'us-east', + label: 'Newark, NJ', + resolvers: { ipv4: '', ipv6: '' }, + status: 'ok', + }, + { + capabilities: ['Object Storage'], + country: 'us', + id: 'us-southeast', + label: 'Atlanta, GA', + resolvers: { ipv4: '', ipv6: '' }, + status: 'ok', + }, + ]; + + const expectedOutput = { + 'us-east': { + capabilities: ['Object Storage'], + country: 'us', + id: 'us-east', + label: 'Newark, NJ', + resolvers: { ipv4: '', ipv6: '' }, + status: 'ok', + }, + 'us-southeast': { + capabilities: ['Object Storage'], + country: 'us', + id: 'us-southeast', + label: 'Atlanta, GA', + resolvers: { ipv4: '', ipv6: '' }, + status: 'ok', + }, + }; + + expect(getRegionsByRegionId(mockRegions)).toEqual(expectedOutput); + }); + + it('returns an empty object for an empty array', () => { + const mockRegions: Region[] = []; + const expectedOutput = {}; + + expect(getRegionsByRegionId(mockRegions)).toEqual(expectedOutput); + }); + + it('returns an empty object for undefined input', () => { + const mockRegions = undefined; + const expectedOutput = {}; + expect(getRegionsByRegionId(mockRegions)).toEqual(expectedOutput); + }); +}); diff --git a/packages/manager/src/utilities/regions.ts b/packages/manager/src/utilities/regions.ts new file mode 100644 index 00000000000..91f7f62ac3b --- /dev/null +++ b/packages/manager/src/utilities/regions.ts @@ -0,0 +1,18 @@ +import { Region } from '@linode/api-v4/lib/regions'; + +/** + * This utility function takes an array of regions data and transforms it into a lookup object. + * Each key in the resulting object corresponds to the ID of a region, and its value is the region object itself. + * This is useful for quickly accessing region data by ID. + * + * @returns {Object} A lookup object where each key is a region ID and its value is the corresponding region object. + */ +export const getRegionsByRegionId = (regionsData: Region[] | undefined) => { + if (!Array.isArray(regionsData)) { + return {}; + } + return regionsData.reduce((lookup, region) => { + lookup[region.id] = region; + return lookup; + }, {}); +}; From 21dbf0715e1fdbdad7786f3cc0f5bb19bb0d4bc3 Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Thu, 25 Jan 2024 10:54:35 -0500 Subject: [PATCH 16/44] fix: [M3-7692] - VPC Action Buttons Incorrect Color in Dark Mode (#10101) * fix VPC action button colors * Added changeset: VPC Action Buttons Incorrect Color in Dark Mode --------- Co-authored-by: Banks Nussman --- packages/manager/.changeset/pr-10101-fixed-1706061984923.md | 5 +++++ .../manager/src/features/VPCs/VPCDetail/VPCDetail.styles.ts | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 packages/manager/.changeset/pr-10101-fixed-1706061984923.md diff --git a/packages/manager/.changeset/pr-10101-fixed-1706061984923.md b/packages/manager/.changeset/pr-10101-fixed-1706061984923.md new file mode 100644 index 00000000000..3ccfb87d4ec --- /dev/null +++ b/packages/manager/.changeset/pr-10101-fixed-1706061984923.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +VPC Action Buttons Incorrect Color in Dark Mode ([#10101](https://github.com/linode/manager/pull/10101)) diff --git a/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.styles.ts b/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.styles.ts index 393670667ec..5d8f28ef52b 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.styles.ts +++ b/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.styles.ts @@ -9,8 +9,8 @@ export const StyledActionButton = styled(Button, { label: 'StyledActionButton', })(({ theme }) => ({ '&:hover': { - backgroundColor: theme.color.blueDTwhite, - color: theme.color.white, + backgroundColor: theme.color.blue, + color: '#fff', }, color: theme.textColors.linkActiveLight, fontFamily: theme.font.normal, From e0689bdbfcb99a5554d3d318d4ca30084ffcacdd Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Thu, 25 Jan 2024 11:27:13 -0500 Subject: [PATCH 17/44] upcoming: [M3-7701] - Add new ACLB Logo (#10105) * add new logo to primary navigation * remove todo comment * use new logo in the add new menu * Added changeset: Add new ACLB logo --------- Co-authored-by: Banks Nussman --- .../.changeset/pr-10105-upcoming-features-1706135685745.md | 5 +++++ .../manager/src/assets/icons/entityIcons/loadbalancer.svg | 1 + packages/manager/src/components/PrimaryNav/PrimaryNav.tsx | 4 ++-- .../manager/src/features/TopMenu/AddNewMenu/AddNewMenu.tsx | 4 ++-- 4 files changed, 10 insertions(+), 4 deletions(-) create mode 100644 packages/manager/.changeset/pr-10105-upcoming-features-1706135685745.md create mode 100644 packages/manager/src/assets/icons/entityIcons/loadbalancer.svg diff --git a/packages/manager/.changeset/pr-10105-upcoming-features-1706135685745.md b/packages/manager/.changeset/pr-10105-upcoming-features-1706135685745.md new file mode 100644 index 00000000000..03144b81780 --- /dev/null +++ b/packages/manager/.changeset/pr-10105-upcoming-features-1706135685745.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add new ACLB logo ([#10105](https://github.com/linode/manager/pull/10105)) diff --git a/packages/manager/src/assets/icons/entityIcons/loadbalancer.svg b/packages/manager/src/assets/icons/entityIcons/loadbalancer.svg new file mode 100644 index 00000000000..54f10dcd9fe --- /dev/null +++ b/packages/manager/src/assets/icons/entityIcons/loadbalancer.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx index d20995c1415..1780dcbc5aa 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx @@ -11,6 +11,7 @@ import Firewall from 'src/assets/icons/entityIcons/firewall.svg'; import Image from 'src/assets/icons/entityIcons/image.svg'; import Kubernetes from 'src/assets/icons/entityIcons/kubernetes.svg'; import Linode from 'src/assets/icons/entityIcons/linode.svg'; +import LoadBalancer from 'src/assets/icons/entityIcons/loadbalancer.svg'; import Managed from 'src/assets/icons/entityIcons/managed.svg'; import NodeBalancer from 'src/assets/icons/entityIcons/nodebalancer.svg'; import OCA from 'src/assets/icons/entityIcons/oneclick.svg'; @@ -191,8 +192,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { display: 'Global Load Balancers', hide: !flags.aglb, href: '/loadbalancers', - // TODO AGLB: replace icon when available - icon: , + icon: , isBeta: true, }, { diff --git a/packages/manager/src/features/TopMenu/AddNewMenu/AddNewMenu.tsx b/packages/manager/src/features/TopMenu/AddNewMenu/AddNewMenu.tsx index 7a1617df939..68772562d60 100644 --- a/packages/manager/src/features/TopMenu/AddNewMenu/AddNewMenu.tsx +++ b/packages/manager/src/features/TopMenu/AddNewMenu/AddNewMenu.tsx @@ -19,6 +19,7 @@ import FirewallIcon from 'src/assets/icons/entityIcons/firewall.svg'; import KubernetesIcon from 'src/assets/icons/entityIcons/kubernetes.svg'; import LinodeIcon from 'src/assets/icons/entityIcons/linode.svg'; import NodebalancerIcon from 'src/assets/icons/entityIcons/nodebalancer.svg'; +import LoadBalancerIcon from 'src/assets/icons/entityIcons/loadbalancer.svg'; import OneClickIcon from 'src/assets/icons/entityIcons/oneclick.svg'; import VolumeIcon from 'src/assets/icons/entityIcons/volume.svg'; import VPCIcon from 'src/assets/icons/entityIcons/vpc.svg'; @@ -91,8 +92,7 @@ export const AddNewMenu = () => { description: 'Ensure your services are highly available', entity: 'Global Load Balancer', hide: !flags.aglb, - // TODO AGLB: Change this icon to the AGLB icon when available - icon: DomainIcon, + icon: LoadBalancerIcon, link: '/loadbalancers/create', }, { From cb5726d8a1ef82feda3456d54f3a08ec7526bd99 Mon Sep 17 00:00:00 2001 From: cliu-akamai <126020611+cliu-akamai@users.noreply.github.com> Date: Thu, 25 Jan 2024 13:27:11 -0500 Subject: [PATCH 18/44] test: [M3-7483] - Add test to delete users on "Users & Grants" page (#10093) * M3-7483 Add test to delete users on "Users & Grants" page * fix comments * Added changeset: Add regression tests for deleting users on Users & Grants page. --- .../pr-10093-tests-1706031852395.md | 5 + .../e2e/core/account/user-permissions.spec.ts | 101 ++++++++++++++++++ .../cypress/support/intercepts/account.ts | 15 +++ 3 files changed, 121 insertions(+) create mode 100644 packages/manager/.changeset/pr-10093-tests-1706031852395.md diff --git a/packages/manager/.changeset/pr-10093-tests-1706031852395.md b/packages/manager/.changeset/pr-10093-tests-1706031852395.md new file mode 100644 index 00000000000..14db1f8585d --- /dev/null +++ b/packages/manager/.changeset/pr-10093-tests-1706031852395.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add regression tests for deleting users on Users & Grants page. ([#10093](https://github.com/linode/manager/pull/10093)) diff --git a/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts b/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts index 2fb3cbbbf16..e27d91f325d 100644 --- a/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts +++ b/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts @@ -10,6 +10,7 @@ import { mockGetUsers, mockUpdateUser, mockUpdateUserGrants, + mockDeleteUser, } from 'support/intercepts/account'; import { mockAppendFeatureFlags, @@ -904,4 +905,104 @@ describe('User permission management', () => { // redirects to the new user's "User Permissions" page cy.url().should('endWith', `/users/${newUser.username}/permissions`); }); + + it('can delete users', () => { + const mockUser = accountUserFactory.build({ + username: randomLabel(), + restricted: false, + }); + + const username = randomLabel(); + const additionalUser = accountUserFactory.build({ + username: username, + email: `${username}@test.com`, + restricted: false, + }); + + const mockUserGrantsUpdated = grantsFactory.build(); + const mockUserGrants = { + ...mockUserGrantsUpdated, + global: undefined, + }; + + // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. + mockAppendFeatureFlags({ + parentChildAccountAccess: makeFeatureFlagData(false), + }).as('getFeatureFlags'); + mockGetFeatureFlagClientstream().as('getClientStream'); + + mockGetUsers([mockUser, additionalUser]).as('getUsers'); + mockGetUser(mockUser); + mockGetUserGrants(mockUser.username, mockUserGrants); + mockGetUserGrants(additionalUser.username, mockUserGrants); + mockDeleteUser(additionalUser.username).as('deleteUser'); + + // Navigate to Users & Grants page, find mock user, click its "User Permissions" button. + cy.visitWithLogin('/account/users'); + cy.wait('@getUsers'); + + mockGetUsers([mockUser]).as('getUsers'); + + // Confirm that the "Users & Grants" page initially lists the main and additional users + cy.findByText(mockUser.username).should('be.visible'); + cy.findByText(additionalUser.username) + .should('be.visible') + .closest('tr') + .within(() => { + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // the "Confirm Deletion" dialog opens + ui.dialog.findByTitle('Confirm Deletion').within(() => { + ui.button.findByTitle('Cancel').should('be.visible').click(); + }); + // click the "Cancel" button will do nothing + cy.findByText(mockUser.username).should('be.visible'); + cy.findByText(additionalUser.username).should('be.visible'); + + // clicking the "x" button will dismiss the dialog and do nothing + cy.findByText(additionalUser.username) + .should('be.visible') + .closest('tr') + .within(() => { + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + }); + ui.dialog.findByTitle('Confirm Deletion').within(() => { + cy.get('[data-testid="CloseIcon"]').should('be.visible').click(); + }); + cy.findByText(mockUser.username).should('be.visible'); + cy.findByText(additionalUser.username).should('be.visible'); + + // delete the user + cy.findByText(additionalUser.username) + .should('be.visible') + .closest('tr') + .within(() => { + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // the "Confirm Deletion" dialog opens + ui.dialog.findByTitle('Confirm Deletion').within(() => { + ui.button.findByTitle('Delete').should('be.visible').click(); + }); + cy.wait(['@deleteUser', '@getUsers']); + + // the user is deleted + ui.toast.assertMessage( + `User ${additionalUser.username} has been deleted successfully.` + ); + cy.findByText(additionalUser.username).should('not.exist'); + }); }); diff --git a/packages/manager/cypress/support/intercepts/account.ts b/packages/manager/cypress/support/intercepts/account.ts index 41f092a814d..76191d0309f 100644 --- a/packages/manager/cypress/support/intercepts/account.ts +++ b/packages/manager/cypress/support/intercepts/account.ts @@ -121,6 +121,21 @@ export const mockUpdateUser = ( ); }; +/** + * Intercepts DELETE request to remove account user. + * + * @param username - Username of user to delete. + * + * @returns Cypress chainable. + */ +export const mockDeleteUser = (username: string): Cypress.Chainable => { + return cy.intercept( + 'DELETE', + apiMatcher(`account/users/${username}`), + makeResponse() + ); +}; + /** * Intercepts GET request to fetch account user grants and mocks response. * From 9c102cc95effadd6caff276dc2a1dec36b04b552 Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Thu, 25 Jan 2024 13:46:47 -0500 Subject: [PATCH 19/44] chore: Clean up some dependencies (#10099) * clean up * add changesets * update yarn.lock --------- Co-authored-by: Banks Nussman --- .../pr-10099-tech-stories-1706059564066.md | 5 + .../pr-10099-tech-stories-1706059664589.md | 5 + .../pr-10099-tech-stories-1706059705766.md | 5 + packages/manager/package.json | 5 +- yarn.lock | 132 ++++++------------ 5 files changed, 61 insertions(+), 91 deletions(-) create mode 100644 packages/manager/.changeset/pr-10099-tech-stories-1706059564066.md create mode 100644 packages/manager/.changeset/pr-10099-tech-stories-1706059664589.md create mode 100644 packages/manager/.changeset/pr-10099-tech-stories-1706059705766.md diff --git a/packages/manager/.changeset/pr-10099-tech-stories-1706059564066.md b/packages/manager/.changeset/pr-10099-tech-stories-1706059564066.md new file mode 100644 index 00000000000..2f68b19764a --- /dev/null +++ b/packages/manager/.changeset/pr-10099-tech-stories-1706059564066.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Remove unused `@types/reach__router` package ([#10099](https://github.com/linode/manager/pull/10099)) diff --git a/packages/manager/.changeset/pr-10099-tech-stories-1706059664589.md b/packages/manager/.changeset/pr-10099-tech-stories-1706059664589.md new file mode 100644 index 00000000000..c41a9d99491 --- /dev/null +++ b/packages/manager/.changeset/pr-10099-tech-stories-1706059664589.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Remove unused `react-page-visibility` and `@types/react-page-visibility` packages ([#10099](https://github.com/linode/manager/pull/10099)) diff --git a/packages/manager/.changeset/pr-10099-tech-stories-1706059705766.md b/packages/manager/.changeset/pr-10099-tech-stories-1706059705766.md new file mode 100644 index 00000000000..5299287bb41 --- /dev/null +++ b/packages/manager/.changeset/pr-10099-tech-stories-1706059705766.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Move `simple-git` from `dependencies` to `devDependencies` ([#10099](https://github.com/linode/manager/pull/10099)) diff --git a/packages/manager/package.json b/packages/manager/package.json index 6410b928ab1..49358cfe0d5 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -56,7 +56,6 @@ "react-dom": "^17.0.2", "react-dropzone": "~11.2.0", "react-number-format": "^3.5.0", - "react-page-visibility": "^6.2.0", "react-query": "^3.3.2", "react-redux": "~7.1.3", "react-router-dom": "~5.1.2", @@ -72,7 +71,6 @@ "rxjs": "^5.5.6", "sanitize-html": "^2.11.0", "search-string": "^3.1.0", - "simple-git": "^3.19.0", "throttle-debounce": "^2.0.0", "tss-react": "^4.8.2", "typescript-fsa": "^3.0.0", @@ -144,12 +142,10 @@ "@types/node": "^12.7.1", "@types/qrcode.react": "^0.8.0", "@types/ramda": "0.25.16", - "@types/reach__router": "^1.3.10", "@types/react": "^17.0.27", "@types/react-beautiful-dnd": "^13.0.0", "@types/react-csv": "^1.1.3", "@types/react-dom": "^17.0.9", - "@types/react-page-visibility": "^6.4.1", "@types/react-redux": "~7.1.7", "@types/react-router-dom": "~5.1.2", "@types/react-router-hash-link": "^1.2.1", @@ -193,6 +189,7 @@ "eslint-plugin-storybook": "^0.6.15", "eslint-plugin-testing-library": "^3.1.2", "eslint-plugin-xss": "^0.1.10", + "simple-git": "^3.19.0", "factory.ts": "^0.5.1", "glob": "^10.3.1", "jest-axe": "^8.0.0", diff --git a/yarn.lock b/yarn.lock index bf3d60be1bb..d8e8e9700c2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -753,6 +753,11 @@ resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.23.6.tgz#ba1c9e512bda72a47e285ae42aff9d2a635a9e3b" integrity sha512-Z2uID7YJ7oNvAI20O9X0bblw7Qqs8Q2hFy0R9tAfnfLkp5MW0UH9eUvnDSnFwKZ0AvgS1ucqR4KzvVHgnke1VQ== +"@babel/parser@^7.23.9": + version "7.23.9" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.23.9.tgz#7b903b6149b0f8fa7ad564af646c4c38a77fc44b" + integrity sha512-9tcKgqKbs3xGJ+NtKF2ndOBBLVwPjl1SHxPQkd36r3Dlirw3xWUeGaTbqr7uGZcTaxkVNwc+03SVP7aCdWrTlA== + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@^7.23.3": version "7.23.3" resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.23.3.tgz#5cd1c87ba9380d0afb78469292c954fee5d2411a" @@ -1580,10 +1585,10 @@ "@babel/parser" "^7.22.15" "@babel/types" "^7.22.15" -"@babel/traverse@^7.18.9", "@babel/traverse@^7.21.0", "@babel/traverse@^7.21.2", "@babel/traverse@^7.22.1", "@babel/traverse@^7.23.2", "@babel/traverse@^7.23.6", "@babel/traverse@^7.7.0": - version "7.23.7" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.23.7.tgz#9a7bf285c928cb99b5ead19c3b1ce5b310c9c305" - integrity sha512-tY3mM8rH9jM0YHFGyfC0/xf+SB5eKUu7HPj7/k3fpi9dAlsMc5YbQvDi0Sh2QTPXqMhyaAtzAr807TIyfQrmyg== +"@babel/traverse@^7.18.9", "@babel/traverse@^7.21.0", "@babel/traverse@^7.21.2", "@babel/traverse@^7.22.1", "@babel/traverse@^7.23.2", "@babel/traverse@^7.23.3", "@babel/traverse@^7.23.6", "@babel/traverse@^7.7.0": + version "7.23.9" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.23.9.tgz#2f9d6aead6b564669394c5ce0f9302bb65b9d950" + integrity sha512-I/4UJ9vs90OkBtY6iiiTORVMyIhJ4kAVmsKo9KFc8UOxMeUfi2hvtIBsET5u9GizXE6/GFSuKCTNfgCswuEjRg== dependencies: "@babel/code-frame" "^7.23.5" "@babel/generator" "^7.23.6" @@ -1591,8 +1596,8 @@ "@babel/helper-function-name" "^7.23.0" "@babel/helper-hoist-variables" "^7.22.5" "@babel/helper-split-export-declaration" "^7.22.6" - "@babel/parser" "^7.23.6" - "@babel/types" "^7.23.6" + "@babel/parser" "^7.23.9" + "@babel/types" "^7.23.9" debug "^4.3.1" globals "^11.1.0" @@ -1650,6 +1655,15 @@ "@babel/helper-validator-identifier" "^7.22.20" to-fast-properties "^2.0.0" +"@babel/types@^7.23.9": + version "7.23.9" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.23.9.tgz#1dd7b59a9a2b5c87f8b41e52770b5ecbf492e002" + integrity sha512-dQjSq/7HaSjRM43FFGnv5keM2HsxpmyV1PfaSVm0nzzjwwTmjOe6J4bC8e3+pTEIgHaHj+1ZlLThRJ2auc/w1Q== + dependencies: + "@babel/helper-string-parser" "^7.23.4" + "@babel/helper-validator-identifier" "^7.22.20" + to-fast-properties "^2.0.0" + "@base2/pretty-print-object@1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@base2/pretty-print-object/-/pretty-print-object-1.0.1.tgz#371ba8be66d556812dc7fb169ebc3c08378f69d4" @@ -4787,13 +4801,6 @@ resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc" integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw== -"@types/reach__router@^1.3.10": - version "1.3.11" - resolved "https://registry.yarnpkg.com/@types/reach__router/-/reach__router-1.3.11.tgz#528af5d73f76b42cf7de5664cdd1b728dee78e31" - integrity sha512-j23ChnIEiW8aAP4KT8OVyTXOFr+Ri65BDnwzmfHFO9WHypXYevHFjeil1Cj7YH3emfCE924BwAmgW4hOv7Wg3g== - dependencies: - "@types/react" "*" - "@types/react-beautiful-dnd@^13.0.0": version "13.1.3" resolved "https://registry.yarnpkg.com/@types/react-beautiful-dnd/-/react-beautiful-dnd-13.1.3.tgz#9812f6086c4b77ce08c83120788d92084a26db0f" @@ -4829,13 +4836,6 @@ dependencies: "@types/react" "*" -"@types/react-page-visibility@^6.4.1": - version "6.4.1" - resolved "https://registry.yarnpkg.com/@types/react-page-visibility/-/react-page-visibility-6.4.1.tgz#21c3bc4a3f310d38d188916cadc55f2bde65f27d" - integrity sha512-vNlYAqKhB2SU1HmF9ARFTFZN0NSPzWn8HSjBpFqYuQlJhsb/aSYeIZdygeqfSjAg0PZ70id2IFWHGULJwe59Aw== - dependencies: - "@types/react" "*" - "@types/react-redux@^7.1.20", "@types/react-redux@~7.1.7": version "7.1.25" resolved "https://registry.yarnpkg.com/@types/react-redux/-/react-redux-7.1.25.tgz#de841631205b24f9dfb4967dd4a7901e048f9a88" @@ -7574,13 +7574,6 @@ encodeurl@~1.0.2: resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w== -encoding@^0.1.11: - version "0.1.13" - resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.13.tgz#56574afdd791f54a8e9b2785c0582a2d26210fa9" - integrity sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A== - dependencies: - iconv-lite "^0.6.2" - end-of-stream@^1.0.0, end-of-stream@^1.1.0, end-of-stream@^1.4.1: version "1.4.4" resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" @@ -8999,20 +8992,13 @@ github-slugger@^1.0.0: resolved "https://registry.yarnpkg.com/github-slugger/-/github-slugger-1.5.0.tgz#17891bbc73232051474d68bd867a34625c955f7d" integrity sha512-wIh+gKBI9Nshz2o46B0B3f5k/W+WI9ZAv6y5Dn5WJ5SK1t0TnDimB4WE5rmTD05ZAIn8HALCZVmCsvj0w0v0lw== -glob-parent@^5.0.0, glob-parent@^5.1.2, glob-parent@~5.1.2: +glob-parent@^5.0.0, glob-parent@^5.1.2, glob-parent@^6.0.2, glob-parent@~5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== dependencies: is-glob "^4.0.1" -glob-parent@^6.0.2: - version "6.0.2" - resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3" - integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== - dependencies: - is-glob "^4.0.3" - glob-promise@^4.2.0: version "4.2.2" resolved "https://registry.yarnpkg.com/glob-promise/-/glob-promise-4.2.2.tgz#15f44bcba0e14219cd93af36da6bb905ff007877" @@ -9292,10 +9278,12 @@ hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0, hoist-non-react- dependencies: react-is "^16.7.0" -hosted-git-info@^2.1.4: - version "2.8.9" - resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9" - integrity sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw== +hosted-git-info@^2.1.4, hosted-git-info@^5.0.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-5.2.1.tgz#0ba1c97178ef91f3ab30842ae63d6a272341156f" + integrity sha512-xIcQYMnhcx2Nr4JTjsFmwwnr9vldugPy9uVm0o87bjqqWMv9GaqsTeT+i99wTl0mk1uLxJtHxLb8kymqTENQsw== + dependencies: + lru-cache "^7.5.1" html-element-map@^1.2.0: version "1.3.1" @@ -9439,7 +9427,7 @@ iconv-lite@0.4.24, iconv-lite@^0.4.24: dependencies: safer-buffer ">= 2.1.2 < 3" -iconv-lite@0.6.3, iconv-lite@^0.6.2: +iconv-lite@0.6.3: version "0.6.3" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== @@ -9866,7 +9854,7 @@ is-shared-array-buffer@^1.0.2: dependencies: call-bind "^1.0.2" -is-stream@^1.0.1, is-stream@^1.1.0: +is-stream@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" integrity sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ== @@ -10393,7 +10381,7 @@ junit2json@^3.1.4: xml2js "0.6.2" yargs "17.7.2" -kind-of@^6.0.2: +kind-of@^6.0.2, kind-of@^6.0.3: version "6.0.3" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw== @@ -10730,6 +10718,11 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" +lru-cache@^7.5.1: + version "7.18.3" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.18.3.tgz#f793896e0fd0e954a59dfdd82f0773808df6aa89" + integrity sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA== + "lru-cache@^9.1.1 || ^10.0.0": version "10.0.0" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.0.0.tgz#b9e2a6a72a129d81ab317202d93c7691df727e61" @@ -11404,7 +11397,7 @@ minimatch@^9.0.1: dependencies: brace-expansion "^2.0.1" -minimist@^1.2.0, minimist@^1.2.5, minimist@^1.2.6, minimist@^1.2.8: +minimist@^1.2.0, minimist@^1.2.3, minimist@^1.2.5, minimist@^1.2.6, minimist@^1.2.8: version "1.2.8" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== @@ -11620,15 +11613,7 @@ node-fetch-native@^1.0.2: resolved "https://registry.yarnpkg.com/node-fetch-native/-/node-fetch-native-1.0.2.tgz#de3651399fda89a1a7c0bf6e7c4e9c239e8d0697" integrity sha512-KIkvH1jl6b3O7es/0ShyCgWLcfXxlBrLBbP3rOr23WArC66IMcU4DeZEeYEOwnopYhawLTn7/y+YtmASe8DFVQ== -node-fetch@^1.0.1: - version "1.7.3" - resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-1.7.3.tgz#980f6f72d85211a5347c6b2bc18c5b84c3eb47ef" - integrity sha512-NhZ4CsKx7cYm2vSrBAr2PvFOe6sWDf0UYLRqA6svUYg7+/TSfVAu49jYC4BvQ4Sms9SZgdqGBgroqfDhJdTyKQ== - dependencies: - encoding "^0.1.11" - is-stream "^1.0.1" - -node-fetch@^2.0.0, node-fetch@^2.6.7: +node-fetch@^1.0.1, node-fetch@^2.0.0, node-fetch@^2.6.7: version "2.6.9" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.9.tgz#7c7f744b5cc6eb5fd404e0c7a9fec630a55657e6" integrity sha512-DJm/CJkZkRjKKj4Zi4BsKVZh3ValV5IR5s7LVZnW+6YMh0W1BfNA8XSs6DLMGYlId5F3KnA70uu2qepcR08Qqg== @@ -12735,13 +12720,6 @@ react-number-format@^3.5.0: dependencies: prop-types "^15.6.0" -react-page-visibility@^6.2.0: - version "6.4.0" - resolved "https://registry.yarnpkg.com/react-page-visibility/-/react-page-visibility-6.4.0.tgz#0684fe80338e716c9ed2d34169fa3cbb3882096b" - integrity sha512-5vQ0zQU2DvKCQAxle9l5V6uxw2m180Lk7Jem+obmTeQ503fvMJLSUzFgWtTEgUVynhUx2pd+RzafnuMAG8uD6A== - dependencies: - prop-types "^15.7.2" - react-query@^3.3.2: version "3.39.3" resolved "https://registry.yarnpkg.com/react-query/-/react-query-3.39.3.tgz#4cea7127c6c26bdea2de5fb63e51044330b03f35" @@ -13531,17 +13509,7 @@ semver-compare@^1.0.0: resolved "https://registry.yarnpkg.com/semver-compare/-/semver-compare-1.0.0.tgz#0dee216a1c941ab37e9efb1788f6afc5ff5537fc" integrity sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow== -"semver@2 || 3 || 4 || 5", semver@^5.5.0, semver@^5.6.0, semver@^5.7.0, semver@^5.7.1: - version "5.7.2" - resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8" - integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g== - -semver@^6.0.0, semver@^6.1.0, semver@^6.1.2, semver@^6.3.0, semver@^6.3.1: - version "6.3.1" - resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" - integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== - -semver@^7.3.2, semver@^7.3.5, semver@^7.3.7, semver@^7.5.3: +"semver@2 || 3 || 4 || 5", semver@^5.5.0, semver@^5.6.0, semver@^5.7.0, semver@^5.7.1, semver@^6.0.0, semver@^6.1.0, semver@^6.1.2, semver@^6.3.0, semver@^6.3.1, semver@^7.3.2, semver@^7.3.5, semver@^7.3.7, semver@^7.5.2, semver@^7.5.3: version "7.5.4" resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e" integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA== @@ -14652,7 +14620,7 @@ typescript@^4.9.5: resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a" integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g== -ua-parser-js@^0.7.30: +ua-parser-js@^0.7.30, ua-parser-js@^0.7.33: version "0.7.37" resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.37.tgz#e464e66dac2d33a7a1251d7d7a99d6157ec27832" integrity sha512-xV8kqRKM+jhMvcHWUKthV9fNebIzrNy//2O9ZwWcfiBFR5f25XVZPLlEajk/sf3Ra15V92isyQqnIEXRDaZWEA== @@ -15294,7 +15262,7 @@ widest-line@^4.0.1: dependencies: string-width "^5.0.1" -word-wrap@^1.2.3, word-wrap@~1.2.3: +word-wrap@^1.2.3, word-wrap@^1.2.4, word-wrap@~1.2.3: version "1.2.5" resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== @@ -15443,29 +15411,19 @@ yallist@^4.0.0: resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== -yaml@^1.10.0, yaml@^1.7.2: - version "1.10.2" - resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b" - integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== - -yaml@^2.1.1, yaml@^2.2.2: +yaml@^1.10.0, yaml@^1.7.2, yaml@^2.1.1, yaml@^2.2.2, yaml@^2.3.0: version "2.3.4" resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.3.4.tgz#53fc1d514be80aabf386dc6001eb29bf3b7523b2" integrity sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA== -yargs-parser@^11.1.1: - version "11.1.1" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-11.1.1.tgz#879a0865973bca9f6bab5cbdf3b1c67ec7d3bcf4" - integrity sha512-C6kB/WJDiaxONLJQnF8ccx9SEeoTTLek8RVbaOIsrAUS8VrBEXfmeSnCZxygc+XC2sNMBIwOOnfcxiynjHsVSQ== +yargs-parser@^11.1.1, yargs-parser@^18.1.3, yargs-parser@^21.1.1: + version "18.1.3" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0" + integrity sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ== dependencies: camelcase "^5.0.0" decamelize "^1.2.0" -yargs-parser@^21.1.1: - version "21.1.1" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" - integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== - yargs@17.7.2, yargs@^17.3.1: version "17.7.2" resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269" From f0b774e268c8a8b07337e0ab92bb9e5a33e82be5 Mon Sep 17 00:00:00 2001 From: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> Date: Thu, 25 Jan 2024 15:17:23 -0700 Subject: [PATCH 20/44] upcoming: [M3-7481] - Disable creating and editing API tokens for proxy users (#10109) * Disable creating and editing API tokens for proxy users * Added changeset: Disable adding and editing API tokens for proxy users * A feature flag would be nice * Add Cypress test coverage * Fix test comments * Add mocked feature flag to test * Address feedback: use tooltip ui helper in test --- ...r-10109-upcoming-features-1706136926455.md | 5 + .../account/personal-access-tokens.spec.ts | 102 ++++++++++++++++++ .../Profile/APITokens/APITokenMenu.tsx | 6 ++ .../Profile/APITokens/APITokenTable.tsx | 15 +++ 4 files changed, 128 insertions(+) create mode 100644 packages/manager/.changeset/pr-10109-upcoming-features-1706136926455.md diff --git a/packages/manager/.changeset/pr-10109-upcoming-features-1706136926455.md b/packages/manager/.changeset/pr-10109-upcoming-features-1706136926455.md new file mode 100644 index 00000000000..9e907f425dc --- /dev/null +++ b/packages/manager/.changeset/pr-10109-upcoming-features-1706136926455.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Disable adding and editing API tokens for proxy users ([#10109](https://github.com/linode/manager/pull/10109)) diff --git a/packages/manager/cypress/e2e/core/account/personal-access-tokens.spec.ts b/packages/manager/cypress/e2e/core/account/personal-access-tokens.spec.ts index e8921ef1e18..90ea030aaaf 100644 --- a/packages/manager/cypress/e2e/core/account/personal-access-tokens.spec.ts +++ b/packages/manager/cypress/e2e/core/account/personal-access-tokens.spec.ts @@ -4,15 +4,22 @@ import { Token } from '@linode/api-v4/types'; import { appTokenFactory } from 'src/factories/oauth'; +import { profileFactory } from 'src/factories/profile'; import { mockCreatePersonalAccessToken, mockGetAppTokens, mockGetPersonalAccessTokens, + mockGetProfile, mockRevokePersonalAccessToken, mockUpdatePersonalAccessToken, } from 'support/intercepts/profile'; import { randomLabel, randomString } from 'support/util/random'; import { ui } from 'support/ui'; +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; describe('Personal access tokens', () => { /* @@ -224,4 +231,99 @@ describe('Personal access tokens', () => { cy.findByText('No items to display.').should('be.visible'); }); }); + + /* + * - Uses mocked API requests to confirm disabled states for proxy users + * - Confirms that a proxy user cannot create an API token + * - Confirms that a proxy user cannot edit (rename) an API token + * - Confirms that a proxy user can revoke an API token created for them + * - Confirms that token is removed from list after revoking it + */ + it('disables API token creation and editing for a proxy user', () => { + const proxyToken: Token = appTokenFactory.build({ + label: randomLabel(), + token: randomString(64), + }); + const proxyUserProfile = profileFactory.build({ user_type: 'proxy' }); + + // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. + mockAppendFeatureFlags({ + parentChildAccountAccess: makeFeatureFlagData(true), + }).as('getFeatureFlags'); + mockGetFeatureFlagClientstream().as('getClientStream'); + + mockGetProfile(proxyUserProfile); + mockGetPersonalAccessTokens([proxyToken]).as('getTokens'); + mockGetAppTokens([]).as('getAppTokens'); + mockRevokePersonalAccessToken(proxyToken.id).as('revokeToken'); + + cy.visitWithLogin('/profile/tokens'); + cy.wait([ + '@getClientStream', + '@getFeatureFlags', + '@getTokens', + '@getAppTokens', + ]); + + // Find 'Create a Personal Access Token' button, confirm it is disabled and tooltip displays. + ui.button + .findByTitle('Create a Personal Access Token') + .should('be.visible') + .should('be.disabled') + .click(); + + ui.tooltip + .findByText('You can only create tokens for your own company.') + .should('be.visible'); + + // Find token in list, confirm "Rename" is disabled and tooltip displays. + cy.findByText(proxyToken.label) + .should('be.visible') + .closest('tr') + .within(() => { + ui.button + .findByTitle('Rename') + .should('be.visible') + .should('be.disabled') + .click(); + }); + + ui.tooltip + .findByText('Only company users can edit API tokens.') + .should('be.visible'); + + // Confirm that token has not been renamed, initiate revocation. + cy.findByText(proxyToken.label) + .should('be.visible') + .closest('tr') + .within(() => { + ui.button + .findByTitle('Revoke') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + mockGetPersonalAccessTokens([]).as('getTokens'); + ui.dialog + .findByTitle(`Revoke ${proxyToken.label}?`) + .should('be.visible') + .within(() => { + ui.buttonGroup + .findButtonByTitle('Revoke') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm that token is removed from list after revoking. + cy.wait(['@revokeToken', '@getTokens']); + ui.toast.assertMessage(`Successfully revoked ${proxyToken.label}`); + cy.findByLabelText('List of Personal Access Tokens') + .should('be.visible') + .within(() => { + cy.findByText(proxyToken.label).should('not.exist'); + cy.findByText('No items to display.').should('be.visible'); + }); + }); }); diff --git a/packages/manager/src/features/Profile/APITokens/APITokenMenu.tsx b/packages/manager/src/features/Profile/APITokens/APITokenMenu.tsx index e04a0d70eac..4800502e84a 100644 --- a/packages/manager/src/features/Profile/APITokens/APITokenMenu.tsx +++ b/packages/manager/src/features/Profile/APITokens/APITokenMenu.tsx @@ -7,6 +7,7 @@ import { Action, ActionMenu } from 'src/components/ActionMenu/ActionMenu'; import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; interface Props { + isProxyUser: boolean; isThirdPartyAccessToken: boolean; openEditDrawer: (token: Token) => void; openRevokeDialog: (token: Token, type: string) => void; @@ -20,6 +21,7 @@ export const APITokenMenu = (props: Props) => { const matchesSmDown = useMediaQuery(theme.breakpoints.down('md')); const { + isProxyUser, isThirdPartyAccessToken, openEditDrawer, openRevokeDialog, @@ -37,10 +39,12 @@ export const APITokenMenu = (props: Props) => { }, !isThirdPartyAccessToken ? { + disabled: isProxyUser, onClick: () => { openEditDrawer(token); }, title: 'Rename', + tooltip: 'Only company users can edit API tokens.', } : null, { @@ -65,8 +69,10 @@ export const APITokenMenu = (props: Props) => { {actions.map((action) => ( ))} diff --git a/packages/manager/src/features/Profile/APITokens/APITokenTable.tsx b/packages/manager/src/features/Profile/APITokens/APITokenTable.tsx index 8c8ed310e9b..79ead3dc7a3 100644 --- a/packages/manager/src/features/Profile/APITokens/APITokenTable.tsx +++ b/packages/manager/src/features/Profile/APITokens/APITokenTable.tsx @@ -18,8 +18,10 @@ import { StyledTableSortCell } from 'src/components/TableSortCell/StyledTableSor import { TableSortCell } from 'src/components/TableSortCell/TableSortCell'; import { Typography } from 'src/components/Typography'; import { SecretTokenDialog } from 'src/features/Profile/SecretTokenDialog/SecretTokenDialog'; +import { useFlags } from 'src/hooks/useFlags'; import { useOrder } from 'src/hooks/useOrder'; import { usePagination } from 'src/hooks/usePagination'; +import { useProfile } from 'src/queries/profile'; import { useAppTokensQuery, usePersonalAccessTokensQuery, @@ -53,6 +55,8 @@ const PREFERENCE_KEY = 'api-tokens'; export const APITokenTable = (props: Props) => { const { title, type } = props; + const flags = useFlags(); + const { data: profile } = useProfile(); const { handleOrderChange, order, orderBy } = useOrder( { order: 'desc', @@ -78,6 +82,10 @@ export const APITokenTable = (props: Props) => { { '+order': order, '+order_by': orderBy } ); + const isProxyUser = Boolean( + flags.parentChildAccountAccess && profile?.user_type === 'proxy' + ); + const [isCreateOpen, setIsCreateOpen] = React.useState(false); const [isRevokeOpen, setIsRevokeOpen] = React.useState(false); const [isViewOpen, setIsViewOpen] = React.useState(false); @@ -169,6 +177,7 @@ export const APITokenTable = (props: Props) => {
    { {type === 'Personal Access Token' && ( setIsCreateOpen(true)} /> From dfdab4b73321de57edcc6fcc958c2a37b46eee7c Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Fri, 26 Jan 2024 12:39:51 -0500 Subject: [PATCH 21/44] upcoming: [M3-7610] - Placement Groups Detail (#10096) * Initial components * Wrap up with tests * Fix test! * Changeset and cleanup * Feedback * Add test and story * cleanup * feedback * oops fix test * Moar Feedback --- ...r-10096-upcoming-features-1706030600745.md | 5 + .../src/components/Breadcrumb/FinalCrumb.tsx | 1 + .../src/components/Breadcrumb/types.ts | 1 + .../EditableText/EditableText.stories.tsx | 18 +++ .../EditableText/EditableText.test.tsx | 22 ++++ .../components/EditableText/EditableText.tsx | 13 +- .../PlacementGroupsDetail.test.tsx | 69 +++++++++++ .../PlacementGroupsDetail.tsx | 117 ++++++++++++++++++ .../PlacementGroupsRow.tsx | 12 +- .../src/features/PlacementGroups/index.tsx | 16 +-- .../features/PlacementGroups/utils.test.ts | 21 ++++ .../src/features/PlacementGroups/utils.ts | 19 +++ packages/manager/src/mocks/serverHandlers.ts | 10 +- 13 files changed, 306 insertions(+), 18 deletions(-) create mode 100644 packages/manager/.changeset/pr-10096-upcoming-features-1706030600745.md create mode 100644 packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsDetail.test.tsx create mode 100644 packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsDetail.tsx create mode 100644 packages/manager/src/features/PlacementGroups/utils.test.ts create mode 100644 packages/manager/src/features/PlacementGroups/utils.ts diff --git a/packages/manager/.changeset/pr-10096-upcoming-features-1706030600745.md b/packages/manager/.changeset/pr-10096-upcoming-features-1706030600745.md new file mode 100644 index 00000000000..d9751b2d8ed --- /dev/null +++ b/packages/manager/.changeset/pr-10096-upcoming-features-1706030600745.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Placement Groups Detail Page ([#10096](https://github.com/linode/manager/pull/10096)) diff --git a/packages/manager/src/components/Breadcrumb/FinalCrumb.tsx b/packages/manager/src/components/Breadcrumb/FinalCrumb.tsx index 4a2102cb300..cb119c1b920 100644 --- a/packages/manager/src/components/Breadcrumb/FinalCrumb.tsx +++ b/packages/manager/src/components/Breadcrumb/FinalCrumb.tsx @@ -25,6 +25,7 @@ export const FinalCrumb = React.memo((props: Props) => { onCancel={onEditHandlers.onCancel} onEdit={onEditHandlers.onEdit} text={onEditHandlers.editableTextTitle} + textSuffix={onEditHandlers.editableTextTitleSuffix} /> ); } diff --git a/packages/manager/src/components/Breadcrumb/types.ts b/packages/manager/src/components/Breadcrumb/types.ts index b3daee93837..3c169241f64 100644 --- a/packages/manager/src/components/Breadcrumb/types.ts +++ b/packages/manager/src/components/Breadcrumb/types.ts @@ -10,6 +10,7 @@ export interface LabelProps { export interface EditableProps { editableTextTitle: string; + editableTextTitleSuffix?: string; errorText?: string; onCancel: () => void; onEdit: (value: string) => Promise; diff --git a/packages/manager/src/components/EditableText/EditableText.stories.tsx b/packages/manager/src/components/EditableText/EditableText.stories.tsx index 011e063b5c4..e3afe866788 100644 --- a/packages/manager/src/components/EditableText/EditableText.stories.tsx +++ b/packages/manager/src/components/EditableText/EditableText.stories.tsx @@ -24,6 +24,24 @@ export const Default: Story = { }, }; +export const WithSuffix: Story = { + args: { + onCancel: action('onCancel'), + text: 'I have a suffix', + }, + render: (args) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const [, setLocalArgs] = useArgs(); + const onEdit = (updatedText: string) => { + return Promise.resolve(setLocalArgs({ text: updatedText })); + }; + + return ( + + ); + }, +}; + const meta: Meta = { component: EditableText, title: 'Components/Editable Text', diff --git a/packages/manager/src/components/EditableText/EditableText.test.tsx b/packages/manager/src/components/EditableText/EditableText.test.tsx index 91ee4623548..63f2dbe7062 100644 --- a/packages/manager/src/components/EditableText/EditableText.test.tsx +++ b/packages/manager/src/components/EditableText/EditableText.test.tsx @@ -108,4 +108,26 @@ describe('Editable Text', () => { fireEvent.click(saveButton); expect(props.onEdit).toHaveBeenCalled(); }); + + it('appends a suffix to the text when provided', () => { + const { getByRole, getByTestId, getByText } = renderWithTheme( + + ); + + const text = getByText('Edit this suffix'); + expect(text).toBeVisible(); + + const editButton = getByRole('button', { name: BUTTON_LABEL }); + expect(editButton).toBeInTheDocument(); + + fireEvent.click(editButton); + const textfield = getByTestId('textfield-input'); + + expect(textfield).toHaveValue('Edit this'); + + const closeButton = getByTestId(CLOSE_BUTTON_ICON); + fireEvent.click(closeButton); + + expect(getByText('Edit this suffix')).toBeVisible(); + }); }); diff --git a/packages/manager/src/components/EditableText/EditableText.tsx b/packages/manager/src/components/EditableText/EditableText.tsx index edc3d9e75d6..ac7af3a117c 100644 --- a/packages/manager/src/components/EditableText/EditableText.tsx +++ b/packages/manager/src/components/EditableText/EditableText.tsx @@ -122,6 +122,10 @@ interface Props { * The text inside the textbox */ text: string; + /** + * Optional suffix to append to the text when it is not in editing mode + */ + textSuffix?: string; } type PassThroughProps = Props & Omit; @@ -138,6 +142,7 @@ export const EditableText = (props: PassThroughProps) => { onCancel, onEdit, text: propText, + textSuffix, ...rest } = props; @@ -192,7 +197,11 @@ export const EditableText = (props: PassThroughProps) => { } }; const labelText = ( - + ); return !isEditing && !errorText ? ( @@ -239,6 +248,7 @@ export const EditableText = (props: PassThroughProps) => { value={text} />